@openwop/openwop-conformance 2.0.0-rc.1 → 2.0.0-rc.2
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
CHANGED
|
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
112
112
|
|
|
113
113
|
## What's Covered
|
|
114
114
|
|
|
115
|
-
The current suite has
|
|
115
|
+
The current suite has 495 scenario files under `src/scenarios/`.
|
|
116
116
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
117
117
|
- 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.
|
|
118
118
|
- 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.**
|
|
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
|
|
|
457
457
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
458
458
|
| **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. |
|
|
459
459
|
|
|
460
|
-
Current source tree:
|
|
460
|
+
Current source tree: 495 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
461
461
|
|
|
462
462
|
## Remaining Gaps
|
|
463
463
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.2",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.0.0-rc.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.0-rc.2"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 539,
|
|
6
|
+
"tests": 2108,
|
|
7
|
+
"withStableId": 2108,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
9
|
"explicitIds": 2057
|
|
10
10
|
},
|
|
@@ -26501,6 +26501,49 @@
|
|
|
26501
26501
|
}
|
|
26502
26502
|
]
|
|
26503
26503
|
},
|
|
26504
|
+
{
|
|
26505
|
+
"id": "openwop.it.v2-era-2-append-vocabulary.a-host-append-to-an-open-era-2-run-keeps-the-log-in-v1-vocabulary-so-the-whole-l",
|
|
26506
|
+
"file": "v2-era-2-append-vocabulary.test.ts",
|
|
26507
|
+
"line": 48,
|
|
26508
|
+
"title": "a host append to an open era-2 run keeps the log in v1 vocabulary, so the whole log still reads",
|
|
26509
|
+
"explicitId": null,
|
|
26510
|
+
"citations": [
|
|
26511
|
+
{
|
|
26512
|
+
"section": null,
|
|
26513
|
+
"requirement": null,
|
|
26514
|
+
"interpolated": true
|
|
26515
|
+
},
|
|
26516
|
+
{
|
|
26517
|
+
"section": null,
|
|
26518
|
+
"requirement": null,
|
|
26519
|
+
"interpolated": true
|
|
26520
|
+
},
|
|
26521
|
+
{
|
|
26522
|
+
"section": null,
|
|
26523
|
+
"requirement": null,
|
|
26524
|
+
"interpolated": true
|
|
26525
|
+
},
|
|
26526
|
+
{
|
|
26527
|
+
"section": null,
|
|
26528
|
+
"requirement": null,
|
|
26529
|
+
"interpolated": true
|
|
26530
|
+
}
|
|
26531
|
+
]
|
|
26532
|
+
},
|
|
26533
|
+
{
|
|
26534
|
+
"id": "openwop.it.v2-era-2-append-vocabulary.the-run-keeps-the-era-it-was-created-with-an-append-does-not-promote-it-to-era-3",
|
|
26535
|
+
"file": "v2-era-2-append-vocabulary.test.ts",
|
|
26536
|
+
"line": 119,
|
|
26537
|
+
"title": "the run keeps the era it was created with; an append does not promote it to era 3",
|
|
26538
|
+
"explicitId": null,
|
|
26539
|
+
"citations": [
|
|
26540
|
+
{
|
|
26541
|
+
"section": null,
|
|
26542
|
+
"requirement": null,
|
|
26543
|
+
"interpolated": true
|
|
26544
|
+
}
|
|
26545
|
+
]
|
|
26546
|
+
},
|
|
26504
26547
|
{
|
|
26505
26548
|
"id": "openwop.it.v2-era-key.a-run-created-now-carries-eventlogschemaversion-3-on-its-snapshot",
|
|
26506
26549
|
"file": "v2-era-key.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 495,
|
|
5
5
|
"v1": 444,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 52
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1267,6 +1267,9 @@
|
|
|
1267
1267
|
"v2-enum-growth-rule.test.ts": [
|
|
1268
1268
|
2
|
|
1269
1269
|
],
|
|
1270
|
+
"v2-era-2-append-vocabulary.test.ts": [
|
|
1271
|
+
2
|
|
1272
|
+
],
|
|
1270
1273
|
"v2-era-key.test.ts": [
|
|
1271
1274
|
2
|
|
1272
1275
|
],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.0.0-rc.
|
|
4
|
+
"version": "2.0.0-rc.2",
|
|
5
5
|
"corpusTag": null,
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "7997be82785d963789a452f76455dd91f51a03c227e83706325a8b67d3d16aeb",
|
|
13
|
+
"api/v2/asyncapi.yaml": "aeed4cbeb832cfcd4de46639a2b5a8981406d307b73e3a9d11a645eb0726caff",
|
|
14
|
+
"api/v2/openapi.yaml": "cbbf5df16473902c3c36b5b048ba80b5b35336c06fd48c8ba32f4b1858dd1807",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
199
199
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
200
200
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
201
|
-
"spec/v1/core-standard-manifest.json": "
|
|
201
|
+
"spec/v1/core-standard-manifest.json": "6d0dd7cec8085554565060d2b5ed3abdcd3d13d9940404de33cac17633f3c23a",
|
|
202
202
|
"spec/v1/deprecations.json": "520d79927a8ed43ccf62c207a7cf6cf7b19b16e00bbbbdf0410667e939c19d85",
|
|
203
203
|
"spec/v1/deprecations.schema.json": "4cdbbb8eb9c0092913a5704c943880f0c8581bcf0f640a7df5a84105d53c8aee",
|
|
204
204
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -228,7 +228,7 @@
|
|
|
228
228
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
229
229
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
230
230
|
"spec/v2/profiles.json": "1f0ee40491131cf561ef19a0394c47ba476b4567514fe16befb6cada4a10a5a4",
|
|
231
|
-
"spec/v2/release.json": "
|
|
231
|
+
"spec/v2/release.json": "ef05b0ec4ef4296dfbbacf37e97612b3c1f8d11b701890caa7b9c8462eab8359"
|
|
232
232
|
},
|
|
233
|
-
"corpusCommit": "
|
|
233
|
+
"corpusCommit": "18c32663eb8acfe76d9b5906f69c82ed214987e0"
|
|
234
234
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0176 §A / `spec/v2/core/persistence.md` §The writer rule — an append to a
|
|
3
|
+
* run in era `2` uses v1 vocabulary (suite 2.0.0, target major 2; seam-gated).
|
|
4
|
+
*
|
|
5
|
+
* The reader rule translates an era-`2` log through the codemap at the storage
|
|
6
|
+
* boundary. That is only coherent if the log stays in ONE vocabulary: the era
|
|
7
|
+
* key is fixed at run creation and fixes the log's vocabulary for the run's
|
|
8
|
+
* lifetime. A host that upgrades while runs are open and then begins writing v2
|
|
9
|
+
* names into an era-`2` log breaks the reader two ways — a renamed type gets
|
|
10
|
+
* mapped a second time, and a v2-only name is not on the codemap's v1 side at
|
|
11
|
+
* all, so the read fails with `event_type_unmapped`.
|
|
12
|
+
*
|
|
13
|
+
* This is not a hypothetical for a host with human-approval interrupts, where a
|
|
14
|
+
* run can stay open for days across a deploy. Draining era-`2` runs before
|
|
15
|
+
* serving v2 is explicitly not the path (`persistence.md` §"Runs pinned to v1"),
|
|
16
|
+
* so the writer rule is what makes an in-flight run safe across the cut.
|
|
17
|
+
*
|
|
18
|
+
* The witness: seed an era-`2` run that is still `running`, drive one canonical
|
|
19
|
+
* mutation so the HOST's own writer appends a terminal event, then read the
|
|
20
|
+
* whole log back under major 2. A host that appended in v1 vocabulary reads
|
|
21
|
+
* back cleanly; a host that appended in v2 vocabulary fails the read or returns
|
|
22
|
+
* a type the closed v2 registry does not carry.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/persistence.md §The writer rule
|
|
25
|
+
* @see RFCS/0176-v2-persisted-data-and-coexistence.md §A
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest';
|
|
29
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
30
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
31
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
34
|
+
import { codemapV1toV2, era2Gate, eventsOf, pollEvents, seedEra2Log, v1FixtureLog, type ReadEvent } from '../lib/era2-seed.js';
|
|
35
|
+
|
|
36
|
+
const ID = 'openwop.requirement.0176.era-2-append-vocabulary';
|
|
37
|
+
const DOC = 'spec/v2/core/persistence.md §The writer rule';
|
|
38
|
+
|
|
39
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
40
|
+
try {
|
|
41
|
+
return await fn();
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('v2-era-2-append-vocabulary (RFC 0176 §A — the writer rule)', () => {
|
|
48
|
+
it('a host append to an open era-2 run keeps the log in v1 vocabulary, so the whole log still reads', async () => {
|
|
49
|
+
const doc = await v2Discovery().catch(() => null);
|
|
50
|
+
const gate = era2Gate(doc);
|
|
51
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
52
|
+
|
|
53
|
+
// An era-2 log that is still OPEN: `running`, not `completed`. A completed
|
|
54
|
+
// run is never appended to, so it cannot witness the writer rule.
|
|
55
|
+
const seeded = await seedEra2Log(v1FixtureLog().slice(0, 2), 'running');
|
|
56
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
57
|
+
const runId = seeded.runId;
|
|
58
|
+
|
|
59
|
+
const before = await pollEvents(runId);
|
|
60
|
+
if (before === null || before.status !== 200) {
|
|
61
|
+
return softSkip('blocked', `GET /runs/${runId}/events/poll answered ${before?.status ?? 'no response'} on the seeded era-2 run — the log cannot be read back`);
|
|
62
|
+
}
|
|
63
|
+
const seedCount = eventsOf(before).length;
|
|
64
|
+
|
|
65
|
+
// One canonical mutation so the HOST's own writer appends. Cancel is the
|
|
66
|
+
// universally available terminal transition; a host that refuses it on a
|
|
67
|
+
// seeded run records `blocked` rather than a pass.
|
|
68
|
+
const cancelled = await http(() => driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}));
|
|
69
|
+
if (cancelled === null || (cancelled.status !== 200 && cancelled.status !== 202 && cancelled.status !== 204)) {
|
|
70
|
+
return softSkip('blocked', `POST /runs/{runId}:cancel answered ${cancelled?.status ?? 'no response'} on a seeded era-2 run — no canonical mutation drove the host's writer, so the append is unwitnessed`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const after = await pollEvents(runId);
|
|
74
|
+
if (after === null) return softSkip('blocked', 'the event read failed after the append');
|
|
75
|
+
|
|
76
|
+
// Failure mode 1: the read itself refuses. A host that wrote a v2-only name
|
|
77
|
+
// into an era-2 log produces a type the codemap cannot map FROM.
|
|
78
|
+
if (after.status !== 200) {
|
|
79
|
+
const code = readErrorCode(after.json);
|
|
80
|
+
expect(
|
|
81
|
+
code,
|
|
82
|
+
req(ID, DOC, `reading the era-2 log after the host's own append failed with ${after.status} ${String(code)} — an append in v2 vocabulary is exactly what makes a translated read fail, and the era key fixes the log's vocabulary for the run's lifetime`),
|
|
83
|
+
).toBe(null);
|
|
84
|
+
return softSkip('blocked', `the era-2 read failed with ${after.status} after the host's own append — the assertion above already recorded the refusal`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rows = eventsOf(after) as ReadEvent[];
|
|
88
|
+
expect(
|
|
89
|
+
rows.length,
|
|
90
|
+
req(ID, DOC, `the append MUST be visible in the log (seeded ${seedCount}, read ${rows.length} after cancel)`),
|
|
91
|
+
).toBeGreaterThan(seedCount);
|
|
92
|
+
|
|
93
|
+
// Failure mode 2: the read succeeds but returns a name the v2 registry does
|
|
94
|
+
// not carry — a v1 name that survived untranslated because it was written
|
|
95
|
+
// after the upgrade and the host treated the log as era 3.
|
|
96
|
+
const v2Names = new Set(codemapV1toV2().values());
|
|
97
|
+
const v1Names = new Set(codemapV1toV2().keys());
|
|
98
|
+
for (const row of rows.slice(seedCount)) {
|
|
99
|
+
const type = String(row.type ?? '');
|
|
100
|
+
// A renamed type is the discriminator: its v1 spelling must NOT appear on
|
|
101
|
+
// the wire, because every reader translates an era-2 log.
|
|
102
|
+
const renamedV1 = v1Names.has(type) && !v2Names.has(type);
|
|
103
|
+
expect(
|
|
104
|
+
renamedV1,
|
|
105
|
+
req(ID, DOC, `the appended event reads as its v2 name, not its v1 spelling (${type}) — every reader translates an era-2 log, so a v1 name reaching the wire means the append bypassed the storage boundary`),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Sequence stays contiguous across the era boundary the append crosses.
|
|
110
|
+
const seqs = rows.map((r) => Number(r.sequence)).sort((a, b) => a - b);
|
|
111
|
+
for (let i = 0; i < seqs.length; i++) {
|
|
112
|
+
expect(
|
|
113
|
+
seqs[i],
|
|
114
|
+
req(ID, DOC, `the appended rows continue the seeded sequence space without a gap or restart (expected ${i}, got ${String(seqs[i])})`),
|
|
115
|
+
).toBe(i);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('the run keeps the era it was created with; an append does not promote it to era 3', async () => {
|
|
120
|
+
const doc = await v2Discovery().catch(() => null);
|
|
121
|
+
const gate = era2Gate(doc);
|
|
122
|
+
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
123
|
+
|
|
124
|
+
const seeded = await seedEra2Log(v1FixtureLog().slice(0, 2), 'running');
|
|
125
|
+
if (!seeded.ok) return softSkip(seeded.kind, seeded.reason);
|
|
126
|
+
const runId = seeded.runId;
|
|
127
|
+
|
|
128
|
+
await http(() => driver.post(`/runs/${encodeURIComponent(runId)}:cancel`, {}));
|
|
129
|
+
|
|
130
|
+
const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
131
|
+
if (snap === null || snap.status !== 200) {
|
|
132
|
+
return softSkip('blocked', `GET /runs/{runId} answered ${snap?.status ?? 'no response'} — the era key cannot be read back`);
|
|
133
|
+
}
|
|
134
|
+
const era = (snap.json as { eventLogSchemaVersion?: unknown } | null)?.eventLogSchemaVersion;
|
|
135
|
+
expect(
|
|
136
|
+
era === undefined || era === 2,
|
|
137
|
+
req(ID, DOC, `the era key is fixed at run creation: a run seeded in era 2 and then appended to MUST still read as era 2 (got ${String(era)}) — promoting it to 3 would strand the seeded rows in a vocabulary the reader no longer translates`),
|
|
138
|
+
).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|