@openwop/openwop-conformance 2.32.0 → 2.33.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 (34) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -4
  3. package/coverage.md +2 -1
  4. package/dist/cli.js +4 -2
  5. package/dist/lib/certification-bundle-v3.js +12 -2
  6. package/dist/lib/v2-profiles.js +66 -0
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirements.json +58 -14
  10. package/scenario-majors.json +5 -2
  11. package/schemas/CORPUS-STAMP.json +8 -8
  12. package/src/cli.ts +4 -2
  13. package/src/lib/a2a-fake-peer.ts +16 -3
  14. package/src/lib/certification-bundle-v3.ts +11 -2
  15. package/src/lib/durability-watch.ts +53 -0
  16. package/src/lib/mcp-fake-server.ts +13 -0
  17. package/src/lib/v2-profiles.ts +66 -0
  18. package/src/lib/webhook-receiver.ts +24 -5
  19. package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
  20. package/src/scenarios/a2a-peer-authority.test.ts +1 -1
  21. package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
  22. package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
  23. package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
  24. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
  25. package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
  26. package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
  27. package/src/scenarios/v2-durability-recovery.test.ts +13 -21
  28. package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
  29. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
  30. package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
  31. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
  32. package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
  33. package/src/scenarios/v2-webhook-delivery-shape.test.ts +45 -8
  34. package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, type KeyObject } from 'node:crypto';
21
21
  import { profileDerivable, type DiscoveryPayload } from './profiles.js';
22
- import { v2RegistryAvailable } from './v2-profiles.js';
22
+ import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
23
23
 
24
24
  export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
25
25
 
@@ -179,9 +179,18 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
179
179
  }
180
180
  // Relaxations: a relaxed obligation's profile cannot certify (RFC 0173 §A.2).
181
181
  const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
182
+ // Ownership comes from the profile registry, not from the profile's name — see profilesRelaxedBy.
183
+ const relaxedProfiles = profilesRelaxedBy((bundle.host?.relaxations ?? []).map((r) => r.obligation), (bundle.claimedProfiles ?? []).map((p) => p.id));
184
+ // An OBSERVED relaxation nobody declared: the bundle's own results show the
185
+ // host accepting a destination its egress guard MUST refuse. Same denial as a
186
+ // declared one, so declaring nothing is not a way around the rule.
187
+ const observed = profilesDeniedByObservedRelaxation(bundle.results?.requirements ?? [], (bundle.claimedProfiles ?? []).map((p) => p.id));
188
+ for (const p of bundle.claimedProfiles ?? []) {
189
+ if (p.certified && observed.profiles.has(p.id) && !relaxedProfiles.has(p.id)) rejections.push({ kind: 'undeclared-relaxation-observed', profile: p.id, detail: `${p.id} is marked certified, but this bundle's own results show the host running relaxed on ${observed.obligations.join(', ')} with no host.relaxations[] entry recording it (security-defaults.md §Relaxations: every relaxation a host runs under MUST be recorded, and its profile MUST NOT certify)` });
190
+ }
182
191
  const certifiedProfiles: string[] = [];
183
192
  for (const p of bundle.claimedProfiles ?? []) {
184
- if (p.certified && relaxed.size > 0 && [...relaxed].some((o) => p.id.includes(o))) rejections.push({ kind: 'relaxed-profile-certified', profile: p.id, detail: `${p.id} is marked certified while a relaxation on ${[...relaxed].join(', ')} is recorded (RFC 0173 §A.2)` });
193
+ if (p.certified && relaxedProfiles.has(p.id)) rejections.push({ kind: 'relaxed-profile-certified', profile: p.id, detail: `${p.id} is marked certified while a relaxation on ${[...relaxed].join(', ')} is recorded (RFC 0173 §A.2)` });
185
194
  else if (p.certified) certifiedProfiles.push(p.id);
186
195
  }
187
196
  if (expected.blocked > 0 && certifiedProfiles.length > 0) rejections.push({ kind: 'blocked-certified', detail: `${expected.blocked} blocked row(s): a bundle with blocked > 0 does not certify (RFC 0168 §E.1)` });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * RFC 0158 §E item 11 — watching a run across a real process death.
3
+ *
4
+ * Two facts are wanted from one watch: (a) did resumption appear within the
5
+ * budget, and (b) was the run EVER observable as `completed` without having
6
+ * been re-executed. (b) is a statement about two pieces of host state — the
7
+ * status and the log — and they come from two requests, so the ORDER of the
8
+ * reads decides whether a conclusion is sound.
9
+ *
10
+ * Until 2.33.1 each iteration read the LOG, then the STATUS, and latched (b)
11
+ * when the status said `completed` and the (older) log showed no resumption. A
12
+ * host that re-dispatched between the two requests therefore read as "completed
13
+ * un-re-executed" while being neither: the log read predated the re-execution,
14
+ * the status read followed its completion. The staged work is `conformance-noop`,
15
+ * which re-executes in milliseconds, so the window is real — per run roughly
16
+ * (gap between the two requests) / (poll interval), a few percent. Measured on
17
+ * a tier-1 host: after a genuine SIGKILL and a correct recovery by lease expiry
18
+ * (727 s, inside a declared 750 s bound), the row failed with the message
19
+ * "read status completed with 2 run.started" — it printed the re-execution it
20
+ * was denying, because the message used a LATER read than the latch did. It had
21
+ * passed on the two previous runs of the same host and code.
22
+ *
23
+ * The rule: STATUS FIRST, LOG SECOND. A run's log is append-only, so a log read
24
+ * taken AFTER a `completed` status can only show MORE than the status implied.
25
+ * If that later log still shows no resumption, the run really was observable as
26
+ * completed un-re-executed. If it shows resumption, nothing was wrong. If it is
27
+ * unreadable, nothing is concluded — an unreadable log is never evidence.
28
+ *
29
+ * Readers are injected so the ordering itself is testable without a host.
30
+ */
31
+ export interface Observation { readonly readable: boolean; readonly runStarted: number; readonly nodeStarted: number; readonly restored: number }
32
+ export interface Watch { readonly resumedAfterMs: number | null; readonly last: Observation; readonly completedUnresumed: boolean; readonly waitedMs: number }
33
+ export interface WatchIo {
34
+ readStatus(): Promise<string | null>;
35
+ readLog(): Promise<Observation>;
36
+ now(): number;
37
+ sleep(ms: number): Promise<void>;
38
+ }
39
+
40
+ export async function watchForResumption(io: WatchIo, budgetMs: number, resumed: (o: Observation) => boolean, pollMs = 500): Promise<Watch> {
41
+ const t0 = io.now();
42
+ let completedUnresumed = false;
43
+ for (;;) {
44
+ const status = await io.readStatus(); // FIRST
45
+ const last = await io.readLog(); // SECOND — never older than the status it is judged against
46
+ const waitedMs = io.now() - t0;
47
+ const isResumed = last.readable && resumed(last);
48
+ if (status === 'completed' && last.readable && !isResumed) completedUnresumed = true;
49
+ if (isResumed) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
50
+ if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
51
+ await io.sleep(pollMs);
52
+ }
53
+ }
@@ -34,6 +34,7 @@
34
34
  * @see SECURITY/threat-model-prompt-injection.md §"UNTRUSTED marker"
35
35
  */
36
36
 
37
+ import { resolvePublicFront } from './webhook-receiver.js';
37
38
  import { createServer, type Server } from 'node:http';
38
39
  import type { AddressInfo } from 'node:net';
39
40
 
@@ -149,6 +150,18 @@ export class McpFakeServer {
149
150
  return `http://127.0.0.1:${this._boundPort}`;
150
151
  }
151
152
 
153
+ /**
154
+ * The address to hand THE HOST UNDER TEST — `OPENWOP_MCP_FAKE_SERVER_URL` when the operator fronts
155
+ * this server publicly (https, publicly resolvable; validated loudly, same rule
156
+ * as the webhook receiver), else `endpoint()`. The suite's own requests to its
157
+ * own fake keep using `endpoint()`: they need no tunnel and must not depend on
158
+ * one. Pin the listener with the matching `_PORT` variable so the front has a
159
+ * fixed port to forward to.
160
+ */
161
+ hostFacingEndpoint(): string {
162
+ return resolvePublicFront('OPENWOP_MCP_FAKE_SERVER_URL', this.endpoint()).url;
163
+ }
164
+
152
165
  invocations(): readonly McpInvocation[] {
153
166
  return this._invocations;
154
167
  }
@@ -110,3 +110,69 @@ export function v2ProfileIds(doc: DiscoveryPayload): readonly string[] | null {
110
110
  export function v2ProfileDerivable(doc: DiscoveryPayload, profile: string): boolean {
111
111
  return v2ProfileIds(doc)?.includes(profile) ?? false;
112
112
  }
113
+
114
+ /**
115
+ * Which of `profileIds` a relaxed obligation denies (RFC 0173 §A.2;
116
+ * `security-defaults.md` §Relaxations: "a bundle that records a relaxation MUST
117
+ * NOT certify the profile the relaxed obligation belongs to").
118
+ *
119
+ * An obligation is `<family>.<name>`. A profile OWNS a family when its registry
120
+ * predicate lists it — `openwop-core-standard` is built on `interrupt`, `replay`,
121
+ * `webhooks`, `idempotency`, `eventLog`, so a relaxation on any of those denies
122
+ * it. That ownership was always stated in `spec/v2/profiles.json`; nothing read
123
+ * it. Until 2.33.0 the verifier tested whether the profile ID contained the
124
+ * family name, and the emitter tested whether it contained the WHOLE obligation
125
+ * string — two different rules under a comment saying they matched — and since
126
+ * no real v2 profile id contains a family name, a declared relaxation denied
127
+ * nothing on any real host. The one scenario pinning the rule used a fixture
128
+ * profile called `openwop-webhooks`, which is why it stayed green.
129
+ *
130
+ * The id-substring rule is KEPT as a second arm, not replaced: it is how a
131
+ * non-registry profile id (a v1 alias, a fixture) names its family, and
132
+ * dropping it would un-deny something that is denied today. When the registry
133
+ * is unavailable only that arm can run; the caller already records that layout
134
+ * as `derivabilityChecked: false`.
135
+ *
136
+ * ONE function, called by the emitter and the verifier, so the file and the
137
+ * verdict on the file cannot disagree again.
138
+ */
139
+ export function profilesRelaxedBy(obligations: readonly string[], profileIds: readonly string[]): ReadonlySet<string> {
140
+ const families = new Set(obligations.map((o) => o.split('.')[0] ?? '').filter((f) => f.length > 0));
141
+ const out = new Set<string>();
142
+ if (families.size === 0) return out;
143
+ const registry = readRegistry();
144
+ const owned = new Map<string, readonly string[]>();
145
+ for (const p of registry ?? []) {
146
+ if (typeof p.id === 'string') owned.set(p.id, Array.isArray(p.predicate?.families) ? (p.predicate.families as unknown[]).map(String) : []);
147
+ }
148
+ for (const id of profileIds) {
149
+ const ownsOne = (owned.get(id) ?? []).some((f) => families.has(f));
150
+ const namesOne = [...families].some((f) => id.includes(f));
151
+ if (ownsOne || namesOne) out.add(id);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * The requirement whose FAILURE is an observed, undeclared relaxation.
158
+ *
159
+ * `conformance/README.md` said of `OPENWOP_HOST_RELAXATIONS` that "the suite
160
+ * cannot detect an undeclared one". For the egress guard that was never true:
161
+ * a host that ACCEPTS `https://127.0.0.1/…` as a webhook destination has shown
162
+ * on the wire that its guard is open (`webhooks.md`: "a host MUST reject (400
163
+ * webhook_url_rejected) … RFC 1918 and loopback and link-local ranges").
164
+ * `v2-webhook-egress-refusal` records that as `executed-fail` on this id unless
165
+ * the operator DECLARED the relaxation — in which case {@link profilesRelaxedBy}
166
+ * already denies the profile. Either way a profile built on `webhooks` does not
167
+ * certify on a host whose guard is open; this closes the path where it did,
168
+ * silently, because nobody declared anything.
169
+ */
170
+ export const UNDECLARED_RELAXATION_WITNESSES: ReadonlyArray<{ readonly requirementId: string; readonly obligation: string }> = [
171
+ { requirementId: 'openwop.requirement.0171.webhook-egress-refused', obligation: 'webhooks.egress-guard' },
172
+ ];
173
+
174
+ /** Profiles that MUST NOT certify because a row above is `executed-fail` in this bundle's own results. */
175
+ export function profilesDeniedByObservedRelaxation(rows: ReadonlyArray<{ readonly id: string; readonly result: string }>, profileIds: readonly string[]): { readonly profiles: ReadonlySet<string>; readonly obligations: readonly string[] } {
176
+ const obligations = UNDECLARED_RELAXATION_WITNESSES.filter((w) => rows.some((r) => r.id === w.requirementId && r.result === 'executed-fail')).map((w) => w.obligation);
177
+ return { profiles: profilesRelaxedBy(obligations, profileIds), obligations };
178
+ }
@@ -235,7 +235,26 @@ export async function discoverOwnedTenant(
235
235
  * "the host could not be exercised".
236
236
  */
237
237
  export function resolveRegistrationUrl(localUrl: string): { url: string; tunnelled: boolean } {
238
- const raw = process.env.OPENWOP_WEBHOOK_RECEIVER_URL?.trim();
238
+ return resolvePublicFront('OPENWOP_WEBHOOK_RECEIVER_URL', localUrl);
239
+ }
240
+
241
+ /**
242
+ * The same rule, for ANY suite fixture the host under test must reach — the
243
+ * webhook receiver, the A2A fake peer, the MCP fake server. `envName` names the
244
+ * operator's public front for that one fixture; unset means the local address.
245
+ *
246
+ * Generalised in 2.33.0. Until then only the webhook receiver had a front, so a
247
+ * host that advertised `a2a` or `mcp` could be measured ONLY with its egress
248
+ * guard relaxed — those fakes advertised `http://127.0.0.1:<port>` and nothing
249
+ * else — and a relaxed guard is a relaxation the bundle must declare and cannot
250
+ * certify under (`security-defaults.md` §Relaxations). The two production hosts
251
+ * cut relaxation-free only because they advertise neither family. The rule made
252
+ * a host that implements MORE of the protocol LESS able to certify; found when
253
+ * the steward's own reference host turned out to be certifying under an
254
+ * undeclared one.
255
+ */
256
+ export function resolvePublicFront(envName: string, localUrl: string): { url: string; tunnelled: boolean } {
257
+ const raw = process.env[envName]?.trim();
239
258
  if (!raw) return { url: localUrl, tunnelled: false };
240
259
 
241
260
  let parsed: URL;
@@ -243,7 +262,7 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
243
262
  parsed = new URL(raw);
244
263
  } catch {
245
264
  throw new Error(
246
- `OPENWOP_WEBHOOK_RECEIVER_URL is not a valid URL: ${JSON.stringify(raw)}`,
265
+ `${envName} is not a valid URL: ${JSON.stringify(raw)}`,
247
266
  );
248
267
  }
249
268
 
@@ -252,7 +271,7 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
252
271
  // ALLOW_PRIVATE flag insufficient in the first place.
253
272
  if (parsed.protocol !== 'https:') {
254
273
  throw new Error(
255
- `OPENWOP_WEBHOOK_RECEIVER_URL MUST be https: (got ${parsed.protocol}). ` +
274
+ `${envName} MUST be https: (got ${parsed.protocol}). ` +
256
275
  'A plain-http front cannot clear the scheme gate, so it cannot witness this scenario.',
257
276
  );
258
277
  }
@@ -272,13 +291,13 @@ export function resolveRegistrationUrl(localUrl: string): { url: string; tunnell
272
291
  /^(fc|fd)/.test(host);
273
292
  if (isLoopback || isPrivate) {
274
293
  throw new Error(
275
- `OPENWOP_WEBHOOK_RECEIVER_URL MUST be a publicly-resolvable host (got ${parsed.hostname}). ` +
294
+ `${envName} MUST be a publicly-resolvable host (got ${parsed.hostname}). ` +
276
295
  'It is the PUBLIC front for the local receiver — a tunnel or TLS-terminating proxy — ' +
277
296
  'not the receiver address itself.',
278
297
  );
279
298
  }
280
299
 
281
- return { url: raw, tunnelled: true };
300
+ return { url: envName === 'OPENWOP_WEBHOOK_RECEIVER_URL' ? raw : raw.replace(/\/+$/, ''), tunnelled: true };
282
301
  }
283
302
 
284
303
  /**
@@ -62,7 +62,7 @@ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', ()
62
62
  expect(ifaces.map((i) => i.protocolVersion)).toEqual(['1.0', '0.3']);
63
63
  for (const i of ifaces) {
64
64
  expect(i.protocolBinding).toBe('JSONRPC');
65
- expect(i.url.startsWith(peer.endpoint())).toBe(true);
65
+ expect(i.url.startsWith(peer.hostFacingEndpoint())).toBe(true);
66
66
  }
67
67
  expect(card['url'], req('openwop.it.a2a-1-0-agent-card.the-agent-card-is-1-0-shaped-supportedinterfaces-no-top-level-url-protocolversio', 'RFC 0152 §A/§B/§C/§D', '1.0 removed top-level url')).toBeUndefined();
68
68
  expect(card['protocolVersion'], req('openwop.it.a2a-1-0-agent-card.the-agent-card-is-1-0-shaped-supportedinterfaces-no-top-level-url-protocolversio', 'RFC 0152 §A/§B/§C/§D', '1.0 removed top-level protocolVersion (it is per interface)')).toBeUndefined();
@@ -64,7 +64,7 @@ describe('RFC 0152 §E — a2a-peer-no-authority-escalation (host as A2A client,
64
64
  peer.reset();
65
65
  peer.setNextPeerAssertsAuthority(true);
66
66
  const drive = await driver.post('/v1/host/sample/a2a/invoke', {
67
- peerUrl: peer.endpoint(),
67
+ peerUrl: peer.hostFacingEndpoint(),
68
68
  scenario: 'peer-asserts-authority',
69
69
  });
70
70
  if (drive.status === 404 || drive.status === 403) {
@@ -66,7 +66,7 @@ function probePeer(): { url: string; isReal: boolean } | null {
66
66
  const real = process.env.OPENWOP_A2A_REAL_PEER_URL;
67
67
  if (real && real.length > 0) return { url: real.replace(/\/$/, ''), isReal: true };
68
68
  const fake = getA2AFakePeer();
69
- if (fake) return { url: fake.endpoint(), isReal: false };
69
+ if (fake) return { url: fake.hostFacingEndpoint(), isReal: false };
70
70
  return null;
71
71
  }
72
72
 
@@ -78,7 +78,7 @@ describe('RFC 0152 §B — A2A version negotiation', () => {
78
78
  const peer = getA2AFakePeer();
79
79
  if (peer === null) return softSkip('blocked', 'precondition not met — `peer === null` returned early (no fake peer wired in this run) (seam, prior step, or fixture unavailable)'); // no fake peer wired in this run
80
80
  peer.reset();
81
- const drive = await driver.post('/v1/host/sample/a2a/invoke', { peerUrl: peer.endpoint() });
81
+ const drive = await driver.post('/v1/host/sample/a2a/invoke', { peerUrl: peer.hostFacingEndpoint() });
82
82
  if (drive.status === 404 || drive.status === 403) {
83
83
  // Seam absent. RFC 0148 §A: unobservable resolves to `blocked`, not a pass.
84
84
  expect(
@@ -114,7 +114,7 @@ describe('RFC 0152 §B — A2A version negotiation', () => {
114
114
  const preferred = caps?.preferredVersion;
115
115
  peer.reset();
116
116
  const drive = await driver.post('/v1/host/sample/a2a/invoke', {
117
- peerUrl: peer.endpoint(),
117
+ peerUrl: peer.hostFacingEndpoint(),
118
118
  authenticated: true,
119
119
  peerOffersOnly: '0.3',
120
120
  });
@@ -151,7 +151,7 @@ describe('RFC 0152 §B — A2A version negotiation', () => {
151
151
  const peer = getA2AFakePeer();
152
152
  if (peer === null) return softSkip('blocked', 'precondition not met — `peer === null` returned early (seam, prior step, or fixture unavailable)');
153
153
  const drive = await driver.post('/v1/host/sample/a2a/invoke', {
154
- peerUrl: peer.endpoint(),
154
+ peerUrl: peer.hostFacingEndpoint(),
155
155
  requestVersion: '99.0',
156
156
  });
157
157
  if (drive.status === 404 || drive.status === 403) return softSkip('blocked', 'precondition not met — `drive.status === 404 || drive.status === 403` returned early (seam, prior step, or fixture unavailable)');
@@ -47,7 +47,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §D — mcp-extension-o
47
47
  if (server === null) return softSkip('blocked', 'the suite MCP fake server is not started in this run — the host-as-client leg cannot be driven');
48
48
  server.reset();
49
49
  server.setNextResultAssertsAuthority(true);
50
- const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint(), tool: 'echo', arguments: { text: 'opaque' }, scenario: 'extension-asserts-authority' });
50
+ const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.hostFacingEndpoint(), tool: 'echo', arguments: { text: 'opaque' }, scenario: 'extension-asserts-authority' });
51
51
  if (drive.status === 404 || drive.status === 403) {
52
52
  // Advertised mcp-2026-07-28 but the invoke seam answered {drive.status}: not observable here.
53
53
  // Default mode records `blocked` (RFC 0148 §A); OPENWOP_REQUIRE_BEHAVIOR=true fails
@@ -58,7 +58,7 @@ describe.skipIf(!process.env.OPENWOP_BASE_URL)('RFC 0153 §C — mcp-mrtr-roundt
58
58
  if (server === null) return softSkip('blocked', 'the suite MCP fake server is not started in this run — the client half cannot be driven');
59
59
  server.reset();
60
60
  const drive = await driver.post('/v1/host/sample/mcp/invoke', {
61
- serverUrl: server.endpoint(),
61
+ serverUrl: server.hostFacingEndpoint(),
62
62
  tool: 'needs_input',
63
63
  clientCapabilities: { elicitation: {} },
64
64
  elicitationAnswer: { name: 'Ada' },
@@ -179,7 +179,7 @@ function probeEndpoint(): { url: string; isReal: boolean } | null {
179
179
  const real = process.env.OPENWOP_MCP_REAL_SERVER_URL;
180
180
  if (real && real.length > 0) return { url: real.replace(/\/$/, ''), isReal: true };
181
181
  const fake = getMcpFakeServer();
182
- if (fake) return { url: fake.endpoint(), isReal: false };
182
+ if (fake) return { url: fake.hostFacingEndpoint(), isReal: false };
183
183
  return null;
184
184
  }
185
185
 
@@ -86,7 +86,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
86
86
  const server = getMcpFakeServer();
87
87
  if (server === null) return softSkip('blocked', 'precondition not met — `server === null` returned early (seam, prior step, or fixture unavailable)');
88
88
  server.reset();
89
- const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint() });
89
+ const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.hostFacingEndpoint() });
90
90
  if (drive.status === 404 || drive.status === 403) {
91
91
  expect(
92
92
  drive.status,
@@ -125,7 +125,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
125
125
  if (server === null) return softSkip('blocked', 'precondition not met — `server === null` returned early (seam, prior step, or fixture unavailable)');
126
126
  const caps = await mcp();
127
127
  server.reset();
128
- const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.endpoint() });
128
+ const drive = await driver.post('/v1/host/sample/mcp/invoke', { serverUrl: server.hostFacingEndpoint() });
129
129
  if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
130
130
  // it never advertised has made its own discovery document unreliable, which
131
131
  // is worse than advertising nothing — a consumer that read it made a
@@ -148,7 +148,7 @@ describe('RFC 0153 §A/§B — MCP revision negotiation', () => {
148
148
  const server = getMcpFakeServer();
149
149
  if (server === null) return softSkip('blocked', 'precondition not met — `server === null` returned early (seam, prior step, or fixture unavailable)');
150
150
  const drive = await driver.post('/v1/host/sample/mcp/invoke', {
151
- serverUrl: server.endpoint(),
151
+ serverUrl: server.hostFacingEndpoint(),
152
152
  requestVersion: '1999-01-01',
153
153
  });
154
154
  if (drive.status === 404 || drive.status === 403) return seamAbsent(`host advertises mcp version negotiation but the invoke seam /v1/host/sample/mcp/invoke answered ${drive.status} — the host-as-client legs are unobservable (host-sample-test-seams.md)`);
@@ -73,6 +73,7 @@ import { softSkip } from '../lib/soft-skip.js';
73
73
  import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
74
74
  import { req } from '../lib/requirement-ids.js';
75
75
  import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
76
+ import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
76
77
 
77
78
  const FIXTURE = 'conformance-noop';
78
79
  const FAILURE_FIXTURE = 'conformance-failure';
@@ -156,7 +157,6 @@ async function waitBack(deadlineMs: number): Promise<number | null> {
156
157
  * minted for it is not failed for declining to re-emit `run.started`.
157
158
  */
158
159
  const RESTORED_TYPES = new Set(['workflow.restored', 'run.restored-from-snapshot']);
159
- interface Observation { readable: boolean; runStarted: number; nodeStarted: number; restored: number }
160
160
  async function observe(runId: string): Promise<Observation> {
161
161
  const events = await runEvents(runId);
162
162
  if (events === null) return { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
@@ -214,26 +214,18 @@ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared:
214
214
  : { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
215
215
  }
216
216
 
217
- interface Watch { resumedAfterMs: number | null; last: Observation; completedUnresumed: boolean; waitedMs: number }
218
217
  /**
219
- * Observe `runId` from the moment the service answers again until `resumed`
220
- * holds or `budgetMs` elapses. `completedUnresumed` latches if ANY observation
221
- * shows the run `completed` while `resumed` is still false — the item-11 defect
222
- * is a state a later observation can paper over, so it is checked at every read.
218
+ * Observe `runId` until `resumed` holds or `budgetMs` elapses. The loop itself —
219
+ * and the reason the STATUS is read before the LOG — lives in
220
+ * `lib/durability-watch.ts`, where the ordering is unit-tested without a host.
223
221
  */
224
- async function watchForResumption(runId: string, budgetMs: number, resumed: (o: Observation) => boolean): Promise<Watch> {
225
- const t0 = Date.now();
226
- let last: Observation = { readable: false, runStarted: 0, nodeStarted: 0, restored: 0 };
227
- let completedUnresumed = false;
228
- for (;;) {
229
- last = await observe(runId);
230
- const waitedMs = Date.now() - t0;
231
- if (last.readable && resumed(last)) return { resumedAfterMs: waitedMs, last, completedUnresumed, waitedMs };
232
- const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`);
233
- if ((snap.json as { status?: unknown } | null)?.status === 'completed' && !(last.readable && resumed(last))) completedUnresumed = true;
234
- if (waitedMs >= budgetMs) return { resumedAfterMs: null, last, completedUnresumed, waitedMs };
235
- await new Promise((r) => setTimeout(r, 500));
236
- }
222
+ function watch(runId: string, budgetMs: number, resumed: (o: Observation) => boolean): Promise<Watch> {
223
+ return watchForResumption({
224
+ readStatus: async () => { const snap = await driver.get(`/runs/${encodeURIComponent(runId)}`); const st = (snap.json as { status?: unknown } | null)?.status; return typeof st === 'string' ? st : null; },
225
+ readLog: () => observe(runId),
226
+ now: () => Date.now(),
227
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
228
+ }, budgetMs, resumed);
237
229
  }
238
230
 
239
231
  /**
@@ -293,7 +285,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
293
285
  // host's OWN declared bound elapses, never once at the instant of return.
294
286
  const bound = await declaredBoundMs(fired.json);
295
287
  const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
296
- const w = await watchForResumption(runId, budget, (o) => o.runStarted >= 1 || o.nodeStarted >= 1 || o.restored >= 1);
288
+ const w = await watch(runId, budget, (o) => o.runStarted >= 1 || o.nodeStarted >= 1 || o.restored >= 1);
297
289
  if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
298
290
  if (w.resumedAfterMs === null && bound.ms > OBSERVATION_CEILING_MS) {
299
291
  return softSkip('blocked', `no dispatch observed in ${w.waitedMs}ms, but the host declares a ${bound.ms}ms recovery bound and this run observes for at most ${OBSERVATION_CEILING_MS}ms — a bound longer than the observation ceiling is conformant (§B.6) and is neither witnessed nor refuted here; the operator precondition for this row is OPENWOP_DURABILITY_OBSERVATION_CEILING_MS >= the declared bound`);
@@ -336,7 +328,7 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
336
328
  // further one, or the registry's own recovery event.
337
329
  const bound = await declaredBoundMs(fired.json);
338
330
  const budget = Math.min(bound.ms, OBSERVATION_CEILING_MS);
339
- const w = await watchForResumption(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
331
+ const w = await watch(runId, budget, (o) => o.runStarted > 1 || o.restored >= 1);
340
332
  if (!w.last.readable) return softSkip('blocked', 'GET /runs/{runId}/events/poll did not answer after the restart — resumption is unobservable, and an unreadable log must not read as "nothing resumed"');
341
333
  expect(
342
334
  w.completedUnresumed,
@@ -96,7 +96,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
96
96
  const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
97
97
  await peer.start();
98
98
  try {
99
- const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.endpoint(), authenticated: true, peerOffersOnly: A2A_LOWEST });
99
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: true, peerOffersOnly: A2A_LOWEST });
100
100
  if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
101
101
  await assertRefused('openwop.requirement.0175.minimum-version-refused', 'a2a', floor, res);
102
102
  // The wire leg: the host MUST NOT have spoken the below-floor version to the peer.
@@ -124,7 +124,7 @@ describe('RFC 0175 §D.2 — minimum-version-refused (gated on a2a/mcp + seams)'
124
124
  const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
125
125
  await server.start();
126
126
  try {
127
- const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.endpoint(), requestVersion: MCP_LOWEST });
127
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), requestVersion: MCP_LOWEST });
128
128
  if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
129
129
  await assertRefused('openwop.requirement.0175.minimum-version-refused.mcp', 'mcp', floor, res);
130
130
  for (const c of server.invocations()) {
@@ -73,7 +73,7 @@ describe('RFC 0175 §E.1 — mrtr-rounds-ceiling (gated on mcp + mrtr)', () => {
73
73
  // input_required for `arguments.rounds` retries before completing.
74
74
  const rounds = (maxRounds as number) + 1;
75
75
  const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, {
76
- serverUrl: server.endpoint(),
76
+ serverUrl: server.hostFacingEndpoint(),
77
77
  tool: LOOP_TOOL,
78
78
  arguments: { rounds },
79
79
  clientCapabilities: { elicitation: {} },
@@ -99,7 +99,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
99
99
  const peer = new A2AFakePeer({ protocolVersions: ['0.3'] });
100
100
  await peer.start();
101
101
  try {
102
- const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.endpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
102
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/a2a/invoke`, { peerUrl: peer.hostFacingEndpoint(), authenticated: false, peerOffersOnly: A2A_LOWER });
103
103
  if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises a2a but ${SEAMS_PREFIX}/sample/a2a/invoke answered ${res.status} (host-sample-test-seams.md §22)`);
104
104
  const wire = peer.invocations().filter((i) => i.method !== 'GET').map((i) => String(i.headers['a2a-version'] ?? ''));
105
105
  await assertNotLowered('openwop.requirement.0175.negotiation-authenticated', preferred, A2A_LOWER, res, wire);
@@ -119,7 +119,7 @@ describe('RFC 0175 §D.1 — negotiation-authenticated (gated on a2a/mcp + seams
119
119
  const server = new McpFakeServer({ protocolVersions: ['2025-06-18'] });
120
120
  await server.start();
121
121
  try {
122
- const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.endpoint(), authenticated: false });
122
+ const res = await driver.post(`${SEAMS_PREFIX}/sample/mcp/invoke`, { serverUrl: server.hostFacingEndpoint(), authenticated: false });
123
123
  if (res.status === 404 || res.status === 403 || res.status === 405) return seamAbsent(`host advertises mcp but ${SEAMS_PREFIX}/sample/mcp/invoke answered ${res.status} (host-sample-test-seams.md §23)`);
124
124
  const wire = server.invocations().map((i) => String(i.headers['mcp-protocol-version'] ?? ''));
125
125
  await assertNotLowered('openwop.requirement.0175.negotiation-authenticated.mcp', preferred, MCP_LOWER, res, wire);
@@ -95,7 +95,7 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
95
95
  const validate = payloadValidator();
96
96
  if (!validate) return softSkip('blocked', 'run-event-payloads.schema.json#/$defs/negotiationDecided not readable from SCHEMAS_DIR');
97
97
 
98
- const driven = await drive(protocol, peer.endpoint());
98
+ const driven = await drive(protocol, peer.hostFacingEndpoint());
99
99
  if (!driven) return softSkip('blocked', 'invoke seam unavailable (reason recorded above)');
100
100
  const runId = driven.body['runId'];
101
101
  if (typeof runId !== 'string') {
@@ -117,11 +117,11 @@ async function leg(protocol: 'a2a' | 'mcp', id: string): Promise<void> {
117
117
  expect(
118
118
  p['peerDigest'],
119
119
  req(id, 'interop.md §The audit event', 'peerDigest MUST be the SHA-256 of the peer origin — never the origin in clear (RFC 0175 §D.3)'),
120
- ).toBe(originDigest(peer.endpoint()));
120
+ ).toBe(originDigest(peer.hostFacingEndpoint()));
121
121
  }
122
122
  for (const v of Object.values(p)) {
123
123
  expect(
124
- typeof v === 'string' && v.includes(new URL(peer.endpoint()).host),
124
+ typeof v === 'string' && v.includes(new URL(peer.hostFacingEndpoint()).host),
125
125
  req(id, 'interop.md §The audit event', 'the event is content-free: the peer origin MUST NOT appear in clear in any field'),
126
126
  ).toBe(false);
127
127
  }
@@ -28,6 +28,8 @@ import { generateKeyPairSync } from 'node:crypto';
28
28
  import { signBundleV3, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Relaxation } from '../lib/certification-bundle-v3.js';
29
29
  import { v2Validator } from '../lib/v2.js';
30
30
  import { req } from '../lib/requirement-ids.js';
31
+ import { softSkip } from '../lib/soft-skip.js';
32
+ import { v2RegistryAvailable } from '../lib/v2-profiles.js';
31
33
 
32
34
  const host = generateKeyPairSync('ed25519');
33
35
  const hostPem = host.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
@@ -38,7 +40,7 @@ const ROWS: BundleV3['results']['requirements'] = [
38
40
  ];
39
41
 
40
42
  /** A schema-valid, verifier-clean bundle v3; `relaxations` and the claimed profile vary per leg. */
41
- function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean }): BundleV3 {
43
+ function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean; profileId?: string }): BundleV3 {
42
44
  const unsigned: Omit<BundleV3, 'signature'> = {
43
45
  bundleVersion: '3',
44
46
  generatedAt: '2026-09-03T00:00:00Z',
@@ -52,7 +54,7 @@ function bundle(opts: { relaxations?: BundleV3Relaxation[]; certified: boolean }
52
54
  },
53
55
  discovery: { url: 'https://fixture.invalid/.well-known/openwop', sha256: 'a'.repeat(64), protocolVersions: ['2.0'], preferredVersion: '2.0' },
54
56
  claimedProfiles: [
55
- { id: 'openwop-webhooks', evidenceTier: 'self', witnessCount: 1, certified: opts.certified },
57
+ { id: opts.profileId ?? 'openwop-webhooks', evidenceTier: 'self', witnessCount: 1, certified: opts.certified },
56
58
  { id: 'openwop-discovery-core', evidenceTier: 'self', witnessCount: 1, certified: true },
57
59
  ],
58
60
  results: { totals: { executedPass: ROWS.length, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 }, requirements: ROWS },
@@ -105,6 +107,39 @@ describe('RFC 0173 §A.2 — relaxation-recorded (unaided, fixture bundle)', ()
105
107
  ).not.toContain('relaxed-profile-certified');
106
108
  });
107
109
 
110
+ // Until 2.33.0 every leg above used the FIXTURE profile `openwop-webhooks`, and
111
+ // the verifier matched a relaxation to a profile by testing whether the profile
112
+ // ID contained the family name. No real v2 profile id contains one —
113
+ // `openwop-discovery-core`, `openwop-core-standard`,
114
+ // `openwop-conformance-seams-v2` — so on every real host a DECLARED relaxation
115
+ // denied nothing, and this file stayed green because its fixture was the one
116
+ // id the rule could match. Ownership is what `spec/v2/profiles.json` states:
117
+ // `openwop-core-standard`'s predicate is built on `webhooks`.
118
+ it('a relaxation denies the REAL profile that owns its family, not only one named after it', () => {
119
+ if (!v2RegistryAvailable()) return softSkip('inapplicable', 'spec/v2/profiles.json is absent from this layout — profile ownership cannot be read, so only the id-named arm of the rule can run');
120
+ const EGRESS: BundleV3Relaxation = { obligation: 'webhooks.egress-guard', durability: 'session', reason: 'conformance cut against loopback fixtures' };
121
+ const control = verifyBundleV3(bundle({ certified: true, profileId: 'openwop-core-standard' }));
122
+ expect(
123
+ control.certifiedProfiles,
124
+ req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'control: with no relaxation recorded, the real profile certifies — so the rejection below is the relaxation\'s'),
125
+ ).toContain('openwop-core-standard');
126
+ const relaxed = verifyBundleV3(bundle({ relaxations: [EGRESS], certified: true, profileId: 'openwop-core-standard' }));
127
+ expect(
128
+ relaxed.rejections.find((r) => r.kind === 'relaxed-profile-certified')?.profile,
129
+ req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'a relaxation on `webhooks.*` MUST deny `openwop-core-standard`, whose registry predicate is built on the `webhooks` family — ownership is read from spec/v2/profiles.json, never from the spelling of the profile id'),
130
+ ).toBe('openwop-core-standard');
131
+ expect(
132
+ relaxed.certifiedProfiles,
133
+ req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'the owning profile is not certified, and a profile that does not own the family still is'),
134
+ ).toEqual(['openwop-discovery-core']);
135
+ // A family no claimed profile owns denies nothing — the relaxation is still recorded, which is the obligation.
136
+ const unowned = verifyBundleV3(bundle({ relaxations: [{ ...EGRESS, obligation: 'mcp.egress-guard' }], certified: true, profileId: 'openwop-core-standard' }));
137
+ expect(
138
+ unowned.certifiedProfiles,
139
+ req('openwop.requirement.0173.relaxation-recorded.owned-by-registry', 'security-defaults.md §Relaxations', 'a relaxation on a family outside every claimed profile\'s predicate is scoped to nothing claimed — it does not poison an unrelated profile'),
140
+ ).toContain('openwop-core-standard');
141
+ });
142
+
108
143
  it('durability is a closed set: session | deployment | persisted', () => {
109
144
  const validate = v2Validator('certification-bundle');
110
145
  const withDurability = (durability: string): unknown => {