@openwop/openwop-conformance 1.129.0 → 1.131.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/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/workflow-chain-pack-manifest.schema.json +12 -1
- package/schemas/workflow-definition.schema.json +12 -1
- package/src/lib/workflow-chain-expansion.ts +36 -6
- package/src/scenarios/chain-compensation-expansion.test.ts +28 -0
- package/src/scenarios/compensation-profile.test.ts +14 -0
- package/src/scenarios/compensation-recovery.test.ts +8 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +9 -0
- package/src/scenarios/workspace-behavior.test.ts +7 -6
- package/src/scenarios/workspace-capability-shape.test.ts +3 -2
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.131.0",
|
|
4
|
+
"corpusCommit": "b2a25cf7013d21838b8b5abeaea7dff12355bfc5"
|
|
5
5
|
}
|
|
@@ -490,9 +490,20 @@
|
|
|
490
490
|
"description": "RFC 0151 §C — an inverse action can itself be harmful (RFC 0147 R9), so it may be gated behind the same approval surface as a forward effect."
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
|
+
},
|
|
494
|
+
"irreversibleEffect": {
|
|
495
|
+
"type": "boolean",
|
|
496
|
+
"description": "RFC 0151 §B UQ4 (resolved 2026-08-16), mirrored into chain fragments per RFC 0157 — the fragment author states that this node's committed effect HAS NO INVERSE. Mutually exclusive with `compensation` (host MUST reject both at pack install / chain expansion); expansion copies it onto the expanded `WorkflowNode` unchanged. See `workflow-definition.schema.json` `WorkflowNode.irreversibleEffect`."
|
|
493
497
|
}
|
|
494
498
|
},
|
|
495
|
-
"additionalProperties": false
|
|
499
|
+
"additionalProperties": false,
|
|
500
|
+
"if": {
|
|
501
|
+
"properties": { "irreversibleEffect": { "const": true } },
|
|
502
|
+
"required": ["irreversibleEffect"]
|
|
503
|
+
},
|
|
504
|
+
"then": {
|
|
505
|
+
"not": { "required": ["compensation"] }
|
|
506
|
+
}
|
|
496
507
|
},
|
|
497
508
|
"FragmentEdge": {
|
|
498
509
|
"type": "object",
|
|
@@ -293,9 +293,20 @@
|
|
|
293
293
|
"description": "RFC 0151 \u00a7C \u2014 an inverse action can itself be harmful (RFC 0147 R9), so it may be gated behind the same approval surface as a forward effect."
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
|
+
},
|
|
297
|
+
"irreversibleEffect": {
|
|
298
|
+
"type": "boolean",
|
|
299
|
+
"description": "RFC 0151 \u00a7B (UQ4, resolved 2026-08-16) \u2014 the author states that this node's committed effect HAS NO INVERSE. OPTIONAL; absent or false means nothing (an undeclared compensator is still not implied). Mutually exclusive with `compensation`: a node declaring both is contradictory and a host MUST reject it at registration (`validation_error`). When true and the node's effect committed before an unwind starts, the plan records the node as `irreversible` (never completed) so the \u00a7D rollup caps at `partial` \u2014 a reader can no longer infer a full undo from a `completed` that quietly skipped it."
|
|
296
300
|
}
|
|
297
301
|
},
|
|
298
|
-
"additionalProperties": false
|
|
302
|
+
"additionalProperties": false,
|
|
303
|
+
"if": {
|
|
304
|
+
"properties": { "irreversibleEffect": { "const": true } },
|
|
305
|
+
"required": ["irreversibleEffect"]
|
|
306
|
+
},
|
|
307
|
+
"then": {
|
|
308
|
+
"not": { "required": ["compensation"] }
|
|
309
|
+
}
|
|
299
310
|
},
|
|
300
311
|
"WorkflowEdge": {
|
|
301
312
|
"type": "object",
|
|
@@ -353,10 +353,31 @@ export interface ChainCompensationPolicy {
|
|
|
353
353
|
/** A chain as RFC 0157 sees it: the RFC 0013 shape plus the two optional
|
|
354
354
|
* compensation surfaces. */
|
|
355
355
|
export type WorkflowChainWithCompensation = Omit<WorkflowChain, 'dag'> & {
|
|
356
|
-
dag: {
|
|
356
|
+
dag: {
|
|
357
|
+
nodes: ReadonlyArray<FragmentNode & { compensation?: FragmentNodeCompensation; irreversibleEffect?: boolean }>;
|
|
358
|
+
edges?: ReadonlyArray<FragmentEdge>;
|
|
359
|
+
};
|
|
357
360
|
compensation?: ChainCompensationPolicy;
|
|
358
361
|
};
|
|
359
362
|
|
|
363
|
+
/** Thrown when a fragment node declares BOTH `irreversibleEffect: true` and a
|
|
364
|
+
* `compensation` (RFC 0151 UQ4 / `compensation.md` §B): a contradiction the
|
|
365
|
+
* schema also rejects; expansion refuses it fail-closed rather than pick one.
|
|
366
|
+
* Wire code `chain_irreversible_with_compensation`. */
|
|
367
|
+
export class ChainIrreversibleWithCompensationError extends Error {
|
|
368
|
+
readonly code = 'chain_irreversible_with_compensation' as const;
|
|
369
|
+
constructor(
|
|
370
|
+
public readonly nodeId: string,
|
|
371
|
+
public readonly chainId: string,
|
|
372
|
+
) {
|
|
373
|
+
super(
|
|
374
|
+
`chain_irreversible_with_compensation: fragment node "${nodeId}" in chain "${chainId}" declares both ` +
|
|
375
|
+
'irreversibleEffect: true and a compensation — an effect cannot both have and lack an inverse',
|
|
376
|
+
);
|
|
377
|
+
this.name = 'ChainIrreversibleWithCompensationError';
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
360
381
|
/** Thrown when the parent already carries a `settings.compensation` policy that
|
|
361
382
|
* is not deep-equal to the chain's. Wire code
|
|
362
383
|
* `chain_compensation_policy_conflict` (`workflow-chain-packs.md` §"Error
|
|
@@ -412,8 +433,9 @@ function canonicalJson(v: unknown): string {
|
|
|
412
433
|
|
|
413
434
|
export interface CarriedCompensation {
|
|
414
435
|
/** The expanded fragment with `compensation` carried onto each node that
|
|
415
|
-
* declared one (typeIds validated, params substituted, id refs rewritten)
|
|
416
|
-
|
|
436
|
+
* declared one (typeIds validated, params substituted, id refs rewritten)
|
|
437
|
+
* and `irreversibleEffect` copied verbatim where declared. */
|
|
438
|
+
nodes: ReadonlyArray<ExpandedFragment['nodes'][number] & { compensation?: FragmentNodeCompensation; irreversibleEffect?: boolean }>;
|
|
417
439
|
/** The `settings.compensation` the registered definition MUST carry after
|
|
418
440
|
* this expansion: the parent's when the chain declares none; the chain's
|
|
419
441
|
* when the parent had none; the (equal) shared policy when both agree.
|
|
@@ -433,11 +455,15 @@ export interface CarriedCompensation {
|
|
|
433
455
|
* literals; the recorded-facts rule is unaffected);
|
|
434
456
|
* 6b. fragment node-id references inside `inputMapping` are rewritten with
|
|
435
457
|
* the expansion prefix, exactly as edge refs are;
|
|
458
|
+
* 6c. `irreversibleEffect: true` (RFC 0151 UQ4) is copied onto the expanded
|
|
459
|
+
* node unchanged; a fragment node declaring both it and a `compensation`
|
|
460
|
+
* is refused (`chain_irreversible_with_compensation`) — the schema rejects
|
|
461
|
+
* the shape too, and expansion does not pick a side;
|
|
436
462
|
* 9b. the chain-level policy becomes the definition's `settings.compensation`
|
|
437
463
|
* — copied when the parent has none, accepted when equal, otherwise
|
|
438
464
|
* `chain_compensation_policy_conflict`.
|
|
439
465
|
*
|
|
440
|
-
* @throws ChainUnresolvableTypeIdError, ChainCompensationPolicyConflictError
|
|
466
|
+
* @throws ChainUnresolvableTypeIdError, ChainCompensationPolicyConflictError, ChainIrreversibleWithCompensationError
|
|
441
467
|
*/
|
|
442
468
|
export function carryCompensation(
|
|
443
469
|
chain: WorkflowChainWithCompensation,
|
|
@@ -450,17 +476,21 @@ export function carryCompensation(
|
|
|
450
476
|
const fragmentNodeIds = new Set(srcNodes.map((n) => n.id));
|
|
451
477
|
const byOriginalId = new Map(srcNodes.map((n) => [n.id, n] as const));
|
|
452
478
|
|
|
453
|
-
// 3b
|
|
479
|
+
// 3b (+ 6c's contradiction check, before any node is emitted)
|
|
454
480
|
for (const n of srcNodes) {
|
|
481
|
+
if (n.irreversibleEffect === true && n.compensation !== undefined) {
|
|
482
|
+
throw new ChainIrreversibleWithCompensationError(n.id, chain.chainId);
|
|
483
|
+
}
|
|
455
484
|
if (n.compensation !== undefined && !ctx.isTypeIdResolvable(n.compensation.nodeTypeId)) {
|
|
456
485
|
throw new ChainUnresolvableTypeIdError(n.compensation.nodeTypeId, chain.chainId);
|
|
457
486
|
}
|
|
458
487
|
}
|
|
459
488
|
|
|
460
|
-
// 5b + 6b
|
|
489
|
+
// 5b + 6b + 6c
|
|
461
490
|
const nodes = expanded.nodes.map((en) => {
|
|
462
491
|
const originalId = en.id.startsWith(prefix) ? en.id.slice(prefix.length) : en.id;
|
|
463
492
|
const src = byOriginalId.get(originalId);
|
|
493
|
+
if (src?.irreversibleEffect === true) return { ...en, irreversibleEffect: true };
|
|
464
494
|
if (src?.compensation === undefined) return en;
|
|
465
495
|
const c: FragmentNodeCompensation = { nodeTypeId: src.compensation.nodeTypeId };
|
|
466
496
|
if (src.compensation.inputMapping !== undefined) {
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
expandChainWithCompensation,
|
|
50
50
|
ChainUnresolvableTypeIdError,
|
|
51
51
|
ChainCompensationPolicyConflictError,
|
|
52
|
+
ChainIrreversibleWithCompensationError,
|
|
52
53
|
type WorkflowChainWithCompensation,
|
|
53
54
|
type ChainCompensationPolicy,
|
|
54
55
|
type FragmentNodeCompensation,
|
|
@@ -162,6 +163,33 @@ describe('RFC 0157 — schema: the chain manifest mirrors the compensation shape
|
|
|
162
163
|
noTriggers.compensation = {};
|
|
163
164
|
expect(validate(manifestWith(noTriggers)), 'a policy without triggers is not a policy').toBe(false);
|
|
164
165
|
});
|
|
166
|
+
|
|
167
|
+
it('FragmentNode.irreversibleEffect (RFC 0151 UQ4) is a sibling boolean, mutually exclusive with compensation — in the manifest and in expansion', () => {
|
|
168
|
+
const validate = manifestValidator();
|
|
169
|
+
// The `notify` node declares no compensation; stating its effect is irreversible is valid.
|
|
170
|
+
const irreversible = structuredClone(CHAIN) as unknown as { dag: { nodes: Array<Record<string, unknown>> } };
|
|
171
|
+
const notifyIdx = irreversible.dag.nodes.findIndex((n) => n['id'] === 'notify');
|
|
172
|
+
expect(notifyIdx).toBeGreaterThanOrEqual(0);
|
|
173
|
+
irreversible.dag.nodes[notifyIdx]!['irreversibleEffect'] = true;
|
|
174
|
+
expect(validate(manifestWith(irreversible)), JSON.stringify(validate.errors)).toBe(true);
|
|
175
|
+
// Expansion copies it verbatim onto the expanded node (6c) and touches nothing else.
|
|
176
|
+
const out = expandChainWithCompensation(irreversible as unknown as typeof CHAIN, CTX);
|
|
177
|
+
const notify = out.nodes.find((n) => n.id.endsWith('_notify')) as { irreversibleEffect?: boolean; compensation?: unknown } | undefined;
|
|
178
|
+
expect(notify?.irreversibleEffect).toBe(true);
|
|
179
|
+
expect(notify?.compensation).toBeUndefined();
|
|
180
|
+
// Both on one node: the schema rejects it AND expansion refuses fail-closed before emitting.
|
|
181
|
+
const both = structuredClone(CHAIN) as unknown as { dag: { nodes: Array<Record<string, unknown>> } };
|
|
182
|
+
both.dag.nodes[0]!['irreversibleEffect'] = true; // node 0 (`reserve`) declares a compensation
|
|
183
|
+
expect(validate(manifestWith(both)), 'compensation.md §B: irreversibleEffect: true + compensation is contradictory').toBe(false);
|
|
184
|
+
let thrown: unknown;
|
|
185
|
+
try {
|
|
186
|
+
expandChainWithCompensation(both as unknown as typeof CHAIN, CTX);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
thrown = e;
|
|
189
|
+
}
|
|
190
|
+
expect(thrown).toBeInstanceOf(ChainIrreversibleWithCompensationError);
|
|
191
|
+
expect((thrown as ChainIrreversibleWithCompensationError).code).toBe('chain_irreversible_with_compensation');
|
|
192
|
+
});
|
|
165
193
|
});
|
|
166
194
|
|
|
167
195
|
describe('RFC 0157 — expansion carries the declaration and the policy', () => {
|
|
@@ -130,6 +130,20 @@ describe('RFC 0151 §B — node compensation declaration', () => {
|
|
|
130
130
|
expect(validate({ ...base }), JSON.stringify(validate.errors)).toBe(true);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
it('irreversibleEffect (RFC 0151 UQ4) is a sibling boolean, mutually exclusive with a compensation declaration', () => {
|
|
134
|
+
// A statement that the effect HAS NO INVERSE. Sibling of `compensation`, so
|
|
135
|
+
// `nodeTypeId` stays required and COMPATIBILITY §2.2 is not engaged.
|
|
136
|
+
expect(validate({ ...base, irreversibleEffect: true }), JSON.stringify(validate.errors)).toBe(true);
|
|
137
|
+
expect(validate({ ...base, irreversibleEffect: false, compensation: { nodeTypeId: 'vendor.shop.release' } }), JSON.stringify(validate.errors)).toBe(true);
|
|
138
|
+
// Both = an effect that both has and lacks an inverse. The schema rejects it
|
|
139
|
+
// (`if irreversibleEffect === true then not required compensation`).
|
|
140
|
+
expect(
|
|
141
|
+
validate({ ...base, irreversibleEffect: true, compensation: { nodeTypeId: 'vendor.shop.release' } }),
|
|
142
|
+
'compensation.md §B: a node declaring both irreversibleEffect: true and compensation is contradictory and MUST be rejected',
|
|
143
|
+
).toBe(false);
|
|
144
|
+
expect(validate({ ...base, irreversibleEffect: 'yes' }), 'irreversibleEffect is a boolean').toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
133
147
|
it('the declaration is closed — an unknown key is rejected', () => {
|
|
134
148
|
expect(
|
|
135
149
|
validate({ ...base, compensation: { nodeTypeId: 'x', onFailure: 'ignore' } }),
|
|
@@ -34,6 +34,14 @@
|
|
|
34
34
|
* extension is `blocked` here (named as such via `seamAbsent`) and keeps its
|
|
35
35
|
* `compensation-behavior` witness — the base and the extension are different
|
|
36
36
|
* claims and are recorded separately.
|
|
37
|
+
*
|
|
38
|
+
* Mode matters, and the first host to advertise pointed it out: `seamAbsent`
|
|
39
|
+
* records `blocked` in DEFAULT mode but THROWS under
|
|
40
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` (RFC 0148 §B — an advertised capability
|
|
41
|
+
* whose witness seam is missing is a failure in strict certification, not a
|
|
42
|
+
* disposition). So a host that flips `compensation.supported` on without the
|
|
43
|
+
* §21 recovery extension is honest-red here in strict runs. Wire the extension
|
|
44
|
+
* with the advert, or run default mode and read the `blocked` rows.
|
|
37
45
|
*/
|
|
38
46
|
|
|
39
47
|
import { describe, it, expect } from 'vitest';
|
|
@@ -55,6 +55,15 @@ describe('org-position-no-authority-escalation (RFC 0087 §B, behavioral)', () =
|
|
|
55
55
|
const chart = await getOrgChart();
|
|
56
56
|
if (chart === null) return seamAbsent('host advertises org-position authority but GET /v1/agents/org-chart is not served');
|
|
57
57
|
|
|
58
|
+
// An EMPTY chart has nothing that could carry authority: the invariant is
|
|
59
|
+
// unobservable, and RFC 0148 §A says unobservable is `blocked`, not a
|
|
60
|
+
// zero-assertion pass (which is what this leg recorded on such a host).
|
|
61
|
+
if ((chart.members ?? []).length === 0 && (chart.departments ?? []).length === 0) {
|
|
62
|
+
return seamAbsent('host advertises org-position authority but the org chart is EMPTY — no member or department to check; the invariant is unobservable until the host seeds one');
|
|
63
|
+
}
|
|
64
|
+
// The chart root is a wire object too.
|
|
65
|
+
expectNoAuthority(chart as unknown as Record<string, unknown>, 'the org-chart root');
|
|
66
|
+
|
|
58
67
|
for (const m of chart.members ?? []) {
|
|
59
68
|
expectNoAuthority(m as Record<string, unknown>, 'an org-chart member');
|
|
60
69
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { describe, it, expect } from 'vitest';
|
|
24
24
|
import { driver } from '../lib/driver.js';
|
|
25
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
25
26
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
26
27
|
|
|
27
28
|
interface DiscoveryWorkspace {
|
|
@@ -49,7 +50,7 @@ interface WorkspaceFile {
|
|
|
49
50
|
|
|
50
51
|
describe('workspace-behavior: §C CRUD round-trip (RFC 0059)', () => {
|
|
51
52
|
it('PUT creates v1, GET returns it, list omits content, DELETE removes it', async () => {
|
|
52
|
-
if (!(await workspaceCap())) return;
|
|
53
|
+
if (!(await workspaceCap())) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
|
|
53
54
|
const path = 'behavior/CRUD.md';
|
|
54
55
|
|
|
55
56
|
const created = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'first body' });
|
|
@@ -78,7 +79,7 @@ describe('workspace-behavior: §C CRUD round-trip (RFC 0059)', () => {
|
|
|
78
79
|
|
|
79
80
|
describe('workspace-behavior: §C optimistic concurrency (RFC 0059)', () => {
|
|
80
81
|
it('stale If-Match → 409 workspace_conflict; matching If-Match bumps version', async () => {
|
|
81
|
-
if (!(await workspaceCap())) return;
|
|
82
|
+
if (!(await workspaceCap())) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
|
|
82
83
|
const path = 'behavior/ETAG.md';
|
|
83
84
|
|
|
84
85
|
const v1 = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'v1' });
|
|
@@ -101,7 +102,7 @@ describe('workspace-behavior: §C optimistic concurrency (RFC 0059)', () => {
|
|
|
101
102
|
describe('workspace-behavior: §C size ceiling (RFC 0059)', () => {
|
|
102
103
|
it('content beyond maxFileBytes returns workspace_too_large', async () => {
|
|
103
104
|
const cap = await workspaceCap();
|
|
104
|
-
if (cap === null) return;
|
|
105
|
+
if (cap === null) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
|
|
105
106
|
const max = typeof cap.maxFileBytes === 'number' ? cap.maxFileBytes : 1_048_576;
|
|
106
107
|
const tooBig = 'x'.repeat(max + 1);
|
|
107
108
|
const res = await driver.put(`${FILES}/${encodeURIComponent('behavior/TOOBIG.md')}`, { content: tooBig });
|
|
@@ -112,14 +113,14 @@ describe('workspace-behavior: §C size ceiling (RFC 0059)', () => {
|
|
|
112
113
|
|
|
113
114
|
describe('workspace-behavior: §D run-start snapshot (RFC 0059)', () => {
|
|
114
115
|
it('a run started after a write exposes the workspace snapshot', async () => {
|
|
115
|
-
if (!(await workspaceCap())) return;
|
|
116
|
+
if (!(await workspaceCap())) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
|
|
116
117
|
const path = 'behavior/SNAPSHOT.md';
|
|
117
118
|
await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'snapshot body' });
|
|
118
119
|
|
|
119
120
|
const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
|
|
120
|
-
if (create.status !== 201 && create.status !== 200) return
|
|
121
|
+
if (create.status !== 201 && create.status !== 200) return seamAbsent(`host advertises workspace but the noop fixture run could not be created (POST /v1/runs → ${create.status})`);
|
|
121
122
|
const runId = (create.json as { runId?: string }).runId;
|
|
122
|
-
if (runId === undefined) return;
|
|
123
|
+
if (runId === undefined) return seamAbsent('host advertises workspace but the created run carried no runId');
|
|
123
124
|
|
|
124
125
|
const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
125
126
|
const ws = (snap.json as { workspace?: Array<{ path: string }> }).workspace;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { describe, it, expect } from 'vitest';
|
|
25
25
|
import { driver } from '../lib/driver.js';
|
|
26
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
26
27
|
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
27
28
|
|
|
28
29
|
interface DiscoveryWorkspace {
|
|
@@ -48,7 +49,7 @@ const POSITIVE_INT_FIELDS = ['maxFileBytes', 'maxFiles', 'maxVersions'] as const
|
|
|
48
49
|
describe('workspace-capability-shape: advertisement shape (RFC 0059 §A)', () => {
|
|
49
50
|
it('capabilities.workspace is either absent or well-formed', async () => {
|
|
50
51
|
const ws = await readWorkspace();
|
|
51
|
-
if (ws === null) return
|
|
52
|
+
if (ws === null) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
|
|
52
53
|
expect(
|
|
53
54
|
typeof ws.supported,
|
|
54
55
|
driver.describe(
|
|
@@ -60,7 +61,7 @@ describe('workspace-capability-shape: advertisement shape (RFC 0059 §A)', () =>
|
|
|
60
61
|
|
|
61
62
|
it('maxFileBytes / maxFiles / maxVersions are positive integers when present', async () => {
|
|
62
63
|
const ws = await readWorkspace();
|
|
63
|
-
if (ws === null) return;
|
|
64
|
+
if (ws === null) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
|
|
64
65
|
for (const field of POSITIVE_INT_FIELDS) {
|
|
65
66
|
const v = ws[field];
|
|
66
67
|
if (v === undefined) continue;
|