@openwop/openwop-conformance 1.142.0 → 1.145.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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/capabilities.schema.json +4 -0
- package/src/lib/spec-coherence-registry.test.ts +34 -0
- package/src/scenarios/auth-subject-link.test.ts +152 -0
- package/src/scenarios/tool-hooks-failure-honesty.test.ts +62 -0
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
94
94
|
|
|
95
95
|
## What's Covered
|
|
96
96
|
|
|
97
|
-
The current suite has
|
|
97
|
+
The current suite has 467 scenario files under `src/scenarios/`.
|
|
98
98
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
99
99
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
100
100
|
- 2026-08-18 (suite `1.136.7 → 1.136.8`, SP-04): NEW `spec-section-citations.test.ts` — server-free; a `<doc>.md §"<Section>"` citation of a checked doc MUST resolve to a heading that exists. `storage-adapters.md §"Claim acquisition"` was cited by four artifacts, including a normative MUST in `production-profile.md` §Durability, and the section did not exist for the life of RFC 0009. Scoped to the docs whose section citations carry the durability contract; a corpus-wide sweep finds ~260 unresolved citations that need triage before they can gate.
|
|
@@ -438,7 +438,7 @@ Server-required (added in 1.7.0):
|
|
|
438
438
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
439
439
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
440
440
|
|
|
441
|
-
Current source tree:
|
|
441
|
+
Current source tree: 467 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
442
442
|
|
|
443
443
|
## Remaining Gaps
|
|
444
444
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.145.0",
|
|
4
|
+
"corpusCommit": "55457606be653cb499906ba6f5960c2fdd62017c"
|
|
5
5
|
}
|
|
@@ -3691,6 +3691,10 @@
|
|
|
3691
3691
|
"uniqueItems": true,
|
|
3692
3692
|
"description": "Auth profiles the host claims. Canonical ids: `openwop-audit-log-integrity` (auth-profiles.md \u00a7Audit-log integrity), `openwop-auth-api-key-rotation`, `openwop-auth-oauth2-client-credentials`, `openwop-auth-oidc-user-bearer`, `openwop-auth-mtls`, `openwop-auth-saml` + `openwop-auth-scim` + `openwop-auth-ldap` (RFC 0050 enterprise identity). Clients SHOULD tolerate unknown profile ids."
|
|
3693
3693
|
},
|
|
3694
|
+
"subjectLinking": {
|
|
3695
|
+
"type": "boolean",
|
|
3696
|
+
"description": "RFC 0159 (auth-profiles.md \u00a7Subject linking). When true, this host advertises BOTH `openwop-auth-saml` and `openwop-auth-scim` AND honours the cross-profile subject-linking obligation: it links the SAML and SCIM principals of one human on an opaque, IdP-stable subject id (SCIM `externalId` \u2194 persistent SAML `NameID`), same-tenant only, and on SCIM deactivation fail-closes the linked SAML identity too (composing RFC 0049 \u00a7C) \u2014 so a provisioned leaver cannot still SSO in. The link key MUST NOT be a mutable/PII attribute (email, userName). Optional; absent/false means the SAML and SCIM lanes are independent and the combined leaver guarantee is NOT claimed. A host MUST NOT set this true unless both profiles are advertised and the obligation holds."
|
|
3697
|
+
},
|
|
3694
3698
|
"rotation": {
|
|
3695
3699
|
"type": "object",
|
|
3696
3700
|
"description": "API-key rotation advertisement (auth-profiles.md \u00a7`openwop-auth-api-key-rotation`).",
|
|
@@ -119,3 +119,37 @@ describe('resolveFileRecord classifies a corpus scenario as inapplicable, not bl
|
|
|
119
119
|
expect(r.disposition).toBe('executed-pass');
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
describe('the published layout is what makes these rows comparable across hosts', () => {
|
|
124
|
+
// Load-bearing and, until now, tested nowhere.
|
|
125
|
+
//
|
|
126
|
+
// A spec-coherence row is `inapplicable` only when V1_DIR is null, and V1_DIR
|
|
127
|
+
// is null only when the layout resolves to `published`. Two peers established
|
|
128
|
+
// by measurement what the code implies: `resolveLayout()` keys off PKG_ROOT,
|
|
129
|
+
// not the consuming repo, so an npm-installed consumer's parent is always
|
|
130
|
+
// `node_modules/@openwop/` — which never contains `schemas/` no matter where
|
|
131
|
+
// the host's checkout sits on disk. Every npm-consuming host therefore gets
|
|
132
|
+
// the same answer, and the dispositions are comparable BY CONSTRUCTION.
|
|
133
|
+
//
|
|
134
|
+
// One peer had generalised the opposite way — "the disposition is a property
|
|
135
|
+
// of where the bundle was cut" — from a host whose own runner sets
|
|
136
|
+
// OPENWOP_CONFORMANCE_ROOT when it finds a sibling checkout. True of that
|
|
137
|
+
// host, false of the artifact. The distinction only survives if something
|
|
138
|
+
// holds the artifact to it.
|
|
139
|
+
//
|
|
140
|
+
// The thing that would break it is a change that looks HELPFUL: adding
|
|
141
|
+
// `spec` to `files` so "the corpus tests run for consumers too". That would
|
|
142
|
+
// silently give npm consumers V1_DIR, the 28 would execute instead of
|
|
143
|
+
// flipping, and every host's numbers would shift with no failure anywhere.
|
|
144
|
+
it('the published package ships no spec/ — so V1_DIR is null for npm consumers', () => {
|
|
145
|
+
const pkg = JSON.parse(readFileSync(join(SCENARIOS, '../../package.json'), 'utf8')) as { files: string[] };
|
|
146
|
+
expect(
|
|
147
|
+
pkg.files.filter((f) => f === 'spec' || f.startsWith('spec/')),
|
|
148
|
+
'adding spec/ to `files` would give npm consumers a V1_DIR, so the spec-coherence rows would execute '
|
|
149
|
+
+ 'instead of reporting `inapplicable` — changing every host bundle with no test going red',
|
|
150
|
+
).toEqual([]);
|
|
151
|
+
// schemas/ IS shipped, and is what selects the `published` layout. If this
|
|
152
|
+
// ever stops being true the layout resolves to neither branch.
|
|
153
|
+
expect(pkg.files, 'schemas/ is what makes resolveLayout() pick `published`').toContain('schemas');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-subject-link — RFC 0159: SCIM ⟷ SAML subject linking (the combined
|
|
3
|
+
* leaver contract).
|
|
4
|
+
*
|
|
5
|
+
* Status: DRAFT. RFC 0159 is `Active` (amends RFC 0050). The obligation is
|
|
6
|
+
* documented in `auth-profiles.md` §"Subject linking (SAML ⟷ SCIM)" and is
|
|
7
|
+
* discoverable via `capabilities.auth.subjectLinking`.
|
|
8
|
+
*
|
|
9
|
+
* Advertisement shape runs unconditionally when the flag is set. The
|
|
10
|
+
* cross-lane behavioral legs (a SCIM deactivation fail-closing the linked SAML
|
|
11
|
+
* identity; a mutable-key link never producing a cross-lane pass) are opt-in
|
|
12
|
+
* via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL` (operator-supplied
|
|
13
|
+
* endpoints), following the `auth-scim-profile.test.ts` opt-in precedent.
|
|
14
|
+
* Soft-skips otherwise; soft-skips `blocked` until the host wires the seam.
|
|
15
|
+
*
|
|
16
|
+
* @see RFCS/0159-scim-saml-subject-linking.md
|
|
17
|
+
* @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
24
|
+
|
|
25
|
+
const SAML_PROFILE = 'openwop-auth-saml';
|
|
26
|
+
const SCIM_PROFILE = 'openwop-auth-scim';
|
|
27
|
+
|
|
28
|
+
interface DiscoveryAuth {
|
|
29
|
+
profiles?: string[];
|
|
30
|
+
subjectLinking?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DiscoveryDoc {
|
|
34
|
+
capabilities?: { auth?: DiscoveryAuth };
|
|
35
|
+
extensions?: { auth?: DiscoveryAuth };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function readAuth(): Promise<DiscoveryAuth | null> {
|
|
39
|
+
const res = await driver.get('/.well-known/openwop');
|
|
40
|
+
const body = res.json as DiscoveryDoc | undefined;
|
|
41
|
+
return capabilityFamily<DiscoveryAuth>(body, 'auth') ?? body?.extensions?.auth ?? null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('auth-subject-link: advertisement shape (RFC 0159 §B)', () => {
|
|
45
|
+
it('subjectLinking:true is only claimed alongside both SAML and SCIM profiles', async () => {
|
|
46
|
+
const auth = await readAuth();
|
|
47
|
+
if (auth === null || auth.subjectLinking !== true) {
|
|
48
|
+
return softSkip('inapplicable', 'auth.subjectLinking not claimed');
|
|
49
|
+
}
|
|
50
|
+
const profiles = auth.profiles ?? [];
|
|
51
|
+
expect(
|
|
52
|
+
profiles.includes(SAML_PROFILE) && profiles.includes(SCIM_PROFILE),
|
|
53
|
+
driver.describe(
|
|
54
|
+
'auth-profiles.md §Subject linking',
|
|
55
|
+
'a host MUST NOT set capabilities.auth.subjectLinking:true unless BOTH openwop-auth-saml and openwop-auth-scim are advertised',
|
|
56
|
+
),
|
|
57
|
+
).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('auth-subject-link: cross-lane deactivation (RFC 0159 §A.3 — opt-in)', () => {
|
|
62
|
+
const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
|
|
63
|
+
const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
|
|
64
|
+
|
|
65
|
+
it('a SCIM deactivation fail-closes the linked SAML identity', async () => {
|
|
66
|
+
const auth = await readAuth();
|
|
67
|
+
if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
|
|
68
|
+
if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
|
|
69
|
+
|
|
70
|
+
// 1. Provision a SCIM user carrying an opaque, IdP-stable externalId.
|
|
71
|
+
const externalId = 'idp-op-8f3a';
|
|
72
|
+
const provision = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
73
|
+
scimUrl,
|
|
74
|
+
op: 'create-user',
|
|
75
|
+
externalId,
|
|
76
|
+
userName: 'r.smith',
|
|
77
|
+
});
|
|
78
|
+
if (provision.status === 404) return softSkip('blocked', 'seam unwired');
|
|
79
|
+
expect(provision.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM provisioning MUST succeed')).toBeLessThan(400);
|
|
80
|
+
|
|
81
|
+
// 2. A valid SAML assertion whose persistent NameID equals the externalId
|
|
82
|
+
// links to the same subject and authenticates.
|
|
83
|
+
const before = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: externalId });
|
|
84
|
+
if (before.status === 404) return softSkip('blocked', 'seam unwired');
|
|
85
|
+
expect(
|
|
86
|
+
(before.json as { authenticated?: boolean } | undefined)?.authenticated,
|
|
87
|
+
driver.describe('auth-profiles.md §Subject linking', 'a valid linked SAML assertion authenticates before deactivation'),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
|
|
90
|
+
// 3. SCIM-deactivate the provisioned user.
|
|
91
|
+
const deactivate = await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'deactivate-user', externalId });
|
|
92
|
+
expect(deactivate.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM deactivation MUST succeed')).toBeLessThan(400);
|
|
93
|
+
|
|
94
|
+
// 4. THE CONTRACT: a subsequent SAML assertion for the linked subject MUST
|
|
95
|
+
// NOT yield an authorized decision (fail-closed across the link).
|
|
96
|
+
const after = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: externalId });
|
|
97
|
+
expect(
|
|
98
|
+
(after.json as { authenticated?: boolean } | undefined)?.authenticated === true,
|
|
99
|
+
driver.describe(
|
|
100
|
+
'auth-profiles.md §Subject linking',
|
|
101
|
+
'RFC 0159 §A.3: after a SCIM deactivation the LINKED SAML identity MUST be denied (a provisioned leaver cannot still SSO in)',
|
|
102
|
+
),
|
|
103
|
+
).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('auth-subject-link: link-key hygiene (RFC 0159 §A.2 — opt-in)', () => {
|
|
108
|
+
const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
|
|
109
|
+
const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
|
|
110
|
+
|
|
111
|
+
it('a mutable/PII link key (email) never produces a cross-lane pass', async () => {
|
|
112
|
+
const auth = await readAuth();
|
|
113
|
+
if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
|
|
114
|
+
if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
|
|
115
|
+
|
|
116
|
+
// Attempt to form the cross-lane link on email (a mutable/PII attribute).
|
|
117
|
+
// §A.2 forbids it: the host MUST reject the mutable-key link (4xx) OR
|
|
118
|
+
// decline to apply any cross-lane effect from it. Either way, an email
|
|
119
|
+
// "link" MUST NOT yield an authorized cross-lane pass after a deactivation.
|
|
120
|
+
const link = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
121
|
+
scimUrl,
|
|
122
|
+
op: 'link',
|
|
123
|
+
linkKey: 'email',
|
|
124
|
+
email: 'r.smith@example.test',
|
|
125
|
+
});
|
|
126
|
+
if (link.status === 404) return softSkip('blocked', 'seam unwired');
|
|
127
|
+
|
|
128
|
+
if (link.status >= 400) {
|
|
129
|
+
// Conforming: the host rejected a mutable-key link outright.
|
|
130
|
+
expect(
|
|
131
|
+
link.status,
|
|
132
|
+
driver.describe('auth-profiles.md §Subject linking', 'RFC 0159 §A.2: a link on a mutable/PII key (email) MUST be rejected'),
|
|
133
|
+
).toBeGreaterThanOrEqual(400);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// If the host accepted the request, it MUST NOT have formed a cross-lane
|
|
138
|
+
// link on email: a deactivation via the email "link" MUST NOT deny (or
|
|
139
|
+
// otherwise act on) an unrelated SAML subject, and MUST NOT let a mutable
|
|
140
|
+
// key authorize one. Probe: deactivate by email, then a SAML assertion for
|
|
141
|
+
// a DIFFERENT opaque subject MUST be unaffected by the email operation.
|
|
142
|
+
await driver.post('/v1/host/sample/auth/scim/provision', { scimUrl, op: 'deactivate-user', email: 'r.smith@example.test' });
|
|
143
|
+
const other = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid', nameId: 'idp-op-DIFFERENT' });
|
|
144
|
+
expect(
|
|
145
|
+
(other.json as { linkedDenied?: boolean } | undefined)?.linkedDenied === true,
|
|
146
|
+
driver.describe(
|
|
147
|
+
'auth-profiles.md §Subject linking',
|
|
148
|
+
'RFC 0159 §A.2: a mutable-key (email) operation MUST NOT drive a cross-lane deny on any opaque subject',
|
|
149
|
+
),
|
|
150
|
+
).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-hooks-failure-honesty — RFC 0064 §F. A tool failure MUST be
|
|
3
|
+
* self-describing on the wire: a non-success `agent.toolReturned` MUST carry a
|
|
4
|
+
* failure discriminator — `error` populated (the `_errorObject`) and, when the
|
|
5
|
+
* host advertises `prePostEvents`, `status: 'error'` — and MUST NOT be
|
|
6
|
+
* represented as a bare/empty success. Closes the gap where a failed tool is
|
|
7
|
+
* indistinguishable from one that succeeded-empty (host-program WFAU-4).
|
|
8
|
+
*
|
|
9
|
+
* Gated on `capabilities.toolHooks.supported` + the tool-hooks seam. Drives the
|
|
10
|
+
* seam's `simulateToolError` arm; a host that advertises tool-hooks but has not
|
|
11
|
+
* wired that arm soft-skips (`blocked`) rather than failing — §F and the seam
|
|
12
|
+
* arm land together, so hosts wire it on adoption (the same soft-skip-until-wired
|
|
13
|
+
* discipline the other tool-hooks scenarios use).
|
|
14
|
+
*
|
|
15
|
+
* @see RFCS/0064-tool-invocation-hooks-and-authorization.md §F
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect } from 'vitest';
|
|
19
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
20
|
+
import { driver } from '../lib/driver.js';
|
|
21
|
+
import { readToolHooksCap, invokeToolHook } from '../lib/toolHooks.js';
|
|
22
|
+
|
|
23
|
+
describe('tool-hooks-failure-honesty (RFC 0064 §F)', () => {
|
|
24
|
+
it('a failing tool yields a populated error, never a bare success', async () => {
|
|
25
|
+
const cap = await readToolHooksCap();
|
|
26
|
+
if (cap?.supported !== true) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `cap?.supported !== true` returned early');
|
|
27
|
+
const res = await invokeToolHook({ principal: 'core.system', toolName: 'web.search', simulateToolError: true });
|
|
28
|
+
if (res === null) return softSkip('blocked', 'tool-hooks seam absent — soft-skip');
|
|
29
|
+
|
|
30
|
+
const returned = res.toolReturned ?? {};
|
|
31
|
+
const errRaw = returned['error'];
|
|
32
|
+
const err = errRaw != null && typeof errRaw === 'object' ? (errRaw as Record<string, unknown>) : undefined;
|
|
33
|
+
const isErrorStatus = returned['status'] === 'error';
|
|
34
|
+
|
|
35
|
+
// A host that has not wired the §F simulateToolError arm answers with a
|
|
36
|
+
// success (status 'ok' / an outcome). Soft-skip rather than red-light an
|
|
37
|
+
// honest pre-§F host — the prose MUST and this seam arm adopt together.
|
|
38
|
+
if (err === undefined && !isErrorStatus) {
|
|
39
|
+
return softSkip('blocked', 'host has not wired the RFC 0064 §F simulateToolError arm — soft-skip until adopted');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// §F: the failure carries a populated `_errorObject { code, message }`.
|
|
43
|
+
expect(
|
|
44
|
+
err !== undefined && typeof err['code'] === 'string' && (err['code'] as string).length > 0 && typeof err['message'] === 'string',
|
|
45
|
+
driver.describe('RFC 0064 §F', 'a failed tool MUST populate error {code, message} on agent.toolReturned'),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
|
|
48
|
+
// §F: `error` and `outcome` are mutually exclusive.
|
|
49
|
+
expect(
|
|
50
|
+
returned['outcome'] === undefined,
|
|
51
|
+
driver.describe('RFC 0064 §F', 'error and outcome are mutually exclusive on a failed tool-return'),
|
|
52
|
+
).toBe(true);
|
|
53
|
+
|
|
54
|
+
// §F: a host advertising `prePostEvents` MUST also set `status: 'error'`.
|
|
55
|
+
if (cap['prePostEvents'] === true) {
|
|
56
|
+
expect(
|
|
57
|
+
returned['status'] === 'error',
|
|
58
|
+
driver.describe('RFC 0064 §F', 'a host advertising prePostEvents MUST set status:"error" on a tool failure'),
|
|
59
|
+
).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|