@openwop/openwop-conformance 2.0.0-rc.0 → 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.
Files changed (72) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +209 -14
  3. package/dist/lib/certification-bundle-v3.js +142 -0
  4. package/dist/lib/requirement-ids.js +23 -0
  5. package/dist/lib/requirement-ledger.js +1 -0
  6. package/dist/lib/scenario-disposition.js +5 -1
  7. package/dist/spec-artifacts.lock.json +2 -2
  8. package/package.json +2 -2
  9. package/requirements.json +2744 -4
  10. package/scenario-majors.json +155 -2
  11. package/schemas/CORPUS-STAMP.json +17 -17
  12. package/src/cli.ts +190 -19
  13. package/src/lib/certification-bundle-v3.ts +175 -0
  14. package/src/lib/driver.ts +9 -1
  15. package/src/lib/era2-seed.ts +197 -0
  16. package/src/lib/requirement-ids.ts +21 -0
  17. package/src/lib/requirement-ledger.ts +7 -1
  18. package/src/lib/scenario-disposition.ts +5 -1
  19. package/src/lib/seams.ts +45 -0
  20. package/src/lib/v2.ts +62 -0
  21. package/src/scenarios/v2-approver-enforced.test.ts +97 -0
  22. package/src/scenarios/v2-assurance-downgrade-audited.test.ts +75 -0
  23. package/src/scenarios/v2-bundle-v3-signed.test.ts +96 -0
  24. package/src/scenarios/v2-capabilities-root-closed.test.ts +63 -0
  25. package/src/scenarios/v2-capability-record-shape.test.ts +104 -0
  26. package/src/scenarios/v2-chain-pin-exact.test.ts +118 -0
  27. package/src/scenarios/v2-coherence-not-in-bundle.test.ts +98 -0
  28. package/src/scenarios/v2-compensation-read-projection.test.ts +85 -0
  29. package/src/scenarios/v2-configurable-closed.test.ts +67 -0
  30. package/src/scenarios/v2-dual-stack-negotiation.test.ts +118 -0
  31. package/src/scenarios/v2-effect-identity-business-key.test.ts +132 -0
  32. package/src/scenarios/v2-effect-seam-manifest.test.ts +163 -0
  33. package/src/scenarios/v2-enum-growth-rule.test.ts +69 -0
  34. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +140 -0
  35. package/src/scenarios/v2-era-key.test.ts +86 -0
  36. package/src/scenarios/v2-error-registry.test.ts +89 -0
  37. package/src/scenarios/v2-event-type-closed.test.ts +97 -0
  38. package/src/scenarios/v2-fork-a-v1-run.test.ts +109 -0
  39. package/src/scenarios/v2-form-when-reuses-edge-conditions.test.ts +70 -0
  40. package/src/scenarios/v2-header-scheme.test.ts +67 -0
  41. package/src/scenarios/v2-id-grammar.test.ts +77 -0
  42. package/src/scenarios/v2-idempotency-key-grammar.test.ts +65 -0
  43. package/src/scenarios/v2-interrupt-token-scheme.test.ts +55 -0
  44. package/src/scenarios/v2-lane-issuer-advertised.test.ts +62 -0
  45. package/src/scenarios/v2-legacy-profiles-absent.test.ts +78 -0
  46. package/src/scenarios/v2-manifest-ceiling-refused.test.ts +130 -0
  47. package/src/scenarios/v2-manifest-hatch-carried.test.ts +120 -0
  48. package/src/scenarios/v2-min-client-version.test.ts +48 -0
  49. package/src/scenarios/v2-minimum-version-refused.test.ts +141 -0
  50. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +95 -0
  51. package/src/scenarios/v2-negotiation-authenticated.test.ts +125 -0
  52. package/src/scenarios/v2-negotiation-decided-emitted.test.ts +149 -0
  53. package/src/scenarios/v2-no-transport-advertisement.test.ts +57 -0
  54. package/src/scenarios/v2-owner-subject-required.test.ts +72 -0
  55. package/src/scenarios/v2-pack-isolation.test.ts +147 -0
  56. package/src/scenarios/v2-payload-registry-closed.test.ts +110 -0
  57. package/src/scenarios/v2-peer-dependency-declared.test.ts +132 -0
  58. package/src/scenarios/v2-pinned-run-disposition.test.ts +124 -0
  59. package/src/scenarios/v2-poll-cursor-v2.test.ts +114 -0
  60. package/src/scenarios/v2-preferred-version-default.test.ts +83 -0
  61. package/src/scenarios/v2-profiles-derived-only.test.ts +70 -0
  62. package/src/scenarios/v2-provider-conflict.test.ts +115 -0
  63. package/src/scenarios/v2-refresh-sla.test.ts +78 -0
  64. package/src/scenarios/v2-relaxation-recorded.test.ts +134 -0
  65. package/src/scenarios/v2-revocation-honored.test.ts +66 -0
  66. package/src/scenarios/v2-subject-link-record.test.ts +102 -0
  67. package/src/scenarios/v2-unmapped-type-refused.test.ts +95 -0
  68. package/src/scenarios/v2-v1-events-translated.test.ts +105 -0
  69. package/src/scenarios/v2-v1-signed-webhook-accepted.test.ts +109 -0
  70. package/src/scenarios/v2-webhook-durable-delivery.test.ts +238 -0
  71. package/src/scenarios/v2-well-known-one-resource.test.ts +129 -0
  72. package/src/setup.ts +40 -4
@@ -0,0 +1,163 @@
1
+ /**
2
+ * RFC 0173 §C.1 — `effect-seam-manifest` (suite 2.0.0, target major 2; gated on `replay`).
3
+ *
4
+ * A v2 host that advertises `replay` MUST publish the effect-seam manifest at
5
+ * `GET /host/effect-seams` (`api/v2/openapi.yaml` `getEffectSeamManifest`,
6
+ * `schemas/v2/effect-seam-manifest.schema.json`): one row per outbound effect
7
+ * path the node runtime can reach, every row `guarded: true` with a
8
+ * `guardedBy`. The `replay` facet names the address as the constant
9
+ * `effectSeamsManifest: "/host/effect-seams"` (`spec/v2/facets/replay.schema.json`).
10
+ *
11
+ * Legs:
12
+ * 1. the manifest validates; every row is guarded; at least one row exists
13
+ * for every `kind` the host declares (the manifest is the host's own
14
+ * enumeration — RFC 0140 G7 answered by making the host list it);
15
+ * 2. the facet constant equals the served address;
16
+ * 3. driving one seam of each kind through the suite's receiver and
17
+ * observing no re-fire needs a seam that fires a named manifest row inside
18
+ * a run — none is catalogued (`host-sample-test-seams.md`,
19
+ * `api/seams-v2.yaml`), so that leg records `blocked` naming it.
20
+ *
21
+ * Completeness of the manifest (a seam outside it) is negative-existence and is
22
+ * not asserted here (RFC 0173 falsifiability table, §C.1 row).
23
+ *
24
+ * @see spec/v2/core/replay.md §The effect-seam manifest
25
+ * @see spec/v2/core/security-defaults.md §Replay suppression
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest';
29
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
30
+ import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
31
+ import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
32
+ import { softSkip } from '../lib/soft-skip.js';
33
+ import { req } from '../lib/requirement-ids.js';
34
+
35
+ const MANIFEST_PATH = '/host/effect-seams';
36
+ const KINDS = ['http', 'queue', 'storage', 'provider-sdk', 'webhook-fanout'] as const;
37
+
38
+ interface SeamRow { seam?: unknown; kind?: unknown; guarded?: unknown; guardedBy?: unknown; branchReFires?: unknown }
39
+ interface Manifest { manifestVersion?: unknown; seams?: SeamRow[] }
40
+
41
+ async function discovery(): Promise<Record<string, unknown> | null> {
42
+ try { return await v2Discovery(); } catch { return null; }
43
+ }
44
+
45
+ async function manifest(): Promise<{ status: number; body: Manifest | null }> {
46
+ const res = await driver.get(MANIFEST_PATH);
47
+ return { status: res.status, body: res.status === 200 && res.json && typeof res.json === 'object' ? (res.json as Manifest) : null };
48
+ }
49
+
50
+ describe('RFC 0173 §C.1 — effect-seam-manifest (gated on replay)', () => {
51
+ it('GET /host/effect-seams validates and every row is guarded', async () => {
52
+ const doc = await discovery();
53
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
54
+ const replay = await gateFamily('replay');
55
+ if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
56
+
57
+ const { status, body } = await manifest();
58
+ expect(
59
+ status,
60
+ req('openwop.requirement.0173.effect-seam-manifest', 'replay.md §The effect-seam manifest', 'a host advertising `replay` MUST serve GET /host/effect-seams with 200 (RFC 0173 §C.1)'),
61
+ ).toBe(200);
62
+ const check = v2Validator('effect-seam-manifest')(body);
63
+ expect(
64
+ check.ok,
65
+ req('openwop.requirement.0173.effect-seam-manifest', 'effect-seam-manifest.schema.json', `the manifest MUST validate against schemas/v2/effect-seam-manifest.schema.json: ${check.errors}`),
66
+ ).toBe(true);
67
+ const rows = body?.seams ?? [];
68
+ expect(
69
+ rows.length,
70
+ req('openwop.requirement.0173.effect-seam-manifest', 'replay.md §The effect-seam manifest', 'the manifest MUST enumerate at least one outbound effect seam — a node runtime with no effect path does not need `replay`'),
71
+ ).toBeGreaterThan(0);
72
+ for (const row of rows) {
73
+ expect(
74
+ row.guarded,
75
+ req('openwop.requirement.0173.effect-seam-manifest', 'RFC 0173 §C.1', `every manifest row MUST be guarded: true — seam ${String(row.seam)} (kind ${String(row.kind)})`),
76
+ ).toBe(true);
77
+ expect(
78
+ typeof row.guardedBy === 'string' && row.guardedBy.length > 0,
79
+ req('openwop.requirement.0173.effect-seam-manifest', 'RFC 0173 §C.1', `every row MUST name the mechanism that records the outcome instead of re-firing (guardedBy) — seam ${String(row.seam)}`),
80
+ ).toBe(true);
81
+ expect(
82
+ (KINDS as readonly string[]).includes(String(row.kind)),
83
+ req('openwop.requirement.0173.effect-seam-manifest', 'effect-seam-manifest.schema.json seams[].kind', `kind MUST be one of ${KINDS.join(' | ')} — seam ${String(row.seam)} declares ${String(row.kind)}`),
84
+ ).toBe(true);
85
+ }
86
+ // One row per kind the host declares: the set of kinds the manifest names is
87
+ // the host's declaration, and each named kind is backed by at least one row.
88
+ const declared = new Set(rows.map((r) => String(r.kind)));
89
+ for (const kind of declared) {
90
+ expect(
91
+ rows.filter((r) => r.kind === kind).length,
92
+ req('openwop.requirement.0173.effect-seam-manifest', 'RFC 0173 §C.1', `at least one manifest row per declared kind (${kind})`),
93
+ ).toBeGreaterThanOrEqual(1);
94
+ }
95
+ // RFC 0140 G6: a `branch` re-fire is stated as a permission, and a permission
96
+ // for a mode the host does not offer is a claim about nothing.
97
+ const modes = Array.isArray(replay['modes']) ? (replay['modes'] as unknown[]) : [];
98
+ for (const row of rows.filter((r) => r.branchReFires === true)) {
99
+ expect(
100
+ modes,
101
+ req('openwop.requirement.0173.effect-seam-manifest', 'effect-seam-manifest.schema.json seams[].branchReFires', `seam ${String(row.seam)} states branchReFires: true, so the replay facet MUST offer the branch mode it refers to`),
102
+ ).toContain('branch');
103
+ }
104
+ });
105
+
106
+ it('the replay facet names the manifest address as the constant /host/effect-seams', async () => {
107
+ const doc = await discovery();
108
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
109
+ const replay = await gateFamily('replay');
110
+ if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
111
+ expect(
112
+ replay['effectSeamsManifest'],
113
+ req('openwop.requirement.0173.effect-seam-manifest.facet', 'facets/replay.schema.json effectSeamsManifest', 'the facet MUST carry the constant "/host/effect-seams" (replay.md §The surface)'),
114
+ ).toBe(MANIFEST_PATH);
115
+ });
116
+
117
+ it('driving one seam of each kind observes no re-fire on replay', async () => {
118
+ const doc = await discovery();
119
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
120
+ const replay = await gateFamily('replay');
121
+ if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
122
+ if (!seamsProfileAdvertised(doc)) {
123
+ return softSkip('blocked', `the no-re-fire leg is seam-gated (RFC 0173 falsifiability §B replay row is witnessable-gated only through a seam that fires a named manifest row inside a run) — seams profile not advertised (conformance.seamsProfile)`);
124
+ }
125
+ // The seam is catalogued (api/seams-v2.yaml `fireEffectSeam`). The witness is
126
+ // the host's own Layer-2 ledger, not an externally reachable receiver: fire a
127
+ // guarded row that states `branchReFires: false`, fork the run in `replay`
128
+ // mode, and read GET /runs/{runId}/effects on the fork. Suppression means the
129
+ // fork issues no further attempt for that seam, so its effect ledger cannot
130
+ // grow past the parent's.
131
+ const manifest = await driver.get(MANIFEST_PATH);
132
+ const seamRows: SeamRow[] = manifest.status === 200 && manifest.json && typeof manifest.json === 'object'
133
+ ? ((manifest.json as Manifest).seams ?? []) : [];
134
+ const target = seamRows.find((r) => r.guarded === true && r.branchReFires === false);
135
+ if (!target) return softSkip('inapplicable', `no manifest row is both guarded and branchReFires: false — nothing to witness suppression on (${seamRows.length} row(s) at ${MANIFEST_PATH})`);
136
+ const fired = await driver.post(`${SEAMS_PREFIX}/sample/effect-seams/fire`, { seam: String(target.seam) });
137
+ if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
138
+ return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/effect-seams/fire (answered ${fired.status}) — the no-re-fire leg cannot be driven`);
139
+ }
140
+ const firedBody = fired.json as { runId?: unknown } | null;
141
+ if (fired.status !== 201 || typeof firedBody?.runId !== 'string') {
142
+ return softSkip('blocked', `${SEAMS_PREFIX}/sample/effect-seams/fire answered ${fired.status} without { runId } — the seam contract in api/seams-v2.yaml is 201 { runId }`);
143
+ }
144
+ const parentId = firedBody.runId;
145
+ const parentEffects = await driver.get(`/runs/${parentId}/effects`);
146
+ if (parentEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${parentEffects.status} on the fired run — the ledger is the witness for suppression (RFC 0173 §C.2)`);
147
+ const countOf = (r: OpenWOPResponse): number => {
148
+ const b = r.json as { effects?: unknown } | null;
149
+ return Array.isArray(b?.effects) ? (b.effects as unknown[]).length : 0;
150
+ };
151
+ const forked = await driver.post(`/runs/${parentId}:fork`, { mode: 'replay' });
152
+ const forkBody = forked.json as { runId?: unknown } | null;
153
+ if (forked.status !== 201 || typeof forkBody?.runId !== 'string') {
154
+ return softSkip('blocked', `POST /runs/{runId}:fork mode replay answered ${forked.status} on the fired run — suppression is witnessed on the fork`);
155
+ }
156
+ const forkEffects = await driver.get(`/runs/${String(forkBody.runId)}/effects`);
157
+ if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
158
+ expect(
159
+ countOf(forkEffects),
160
+ req('openwop.requirement.0173.effect-seam-manifest.no-re-fire', 'spec/v2/core/replay.md §The effect-seam manifest', `seam ${String(target.seam)} states branchReFires: false, so a replay fork MUST NOT issue a further attempt through it — the fork's effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
161
+ ).toBeLessThanOrEqual(countOf(parentEffects));
162
+ });
163
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * v2 — `enum-growth-rule` (suite 2.0.0; RFC 0171 §A.1, §A.5;
3
+ * `spec/v2/core/events.md` §"Types" — Growth; `overview.md` §0).
4
+ *
5
+ * Witness class: witnessable — unaided. A registry-backed enum grows by adding
6
+ * a row and regenerating; a consumer MUST accept an unknown registered member
7
+ * and MUST NOT act on it, and a producer MUST NOT emit an unregistered member.
8
+ * The schema half is server-free: `run-event.schema.json`'s `oneOf` accepts a
9
+ * vendor type `<org>.thing` (the org read from the host's `extensions`, `acme`
10
+ * when the host is unreachable or advertises none) that is unknown to this
11
+ * suite, and rejects the reserved `openwop.` prefix. The reader half is the
12
+ * suite's own fold: `event-type-closed` and `payload-registry-closed` ignore a
13
+ * vendor event's payload rather than acting on it, which is what the growth
14
+ * rule requires of a consumer. The producer half is `event-type-closed`.
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
21
+ import { SCHEMAS_DIR } from '../lib/paths.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+
24
+ const DOC = 'spec/v2/core/events.md §Types (Growth)';
25
+
26
+ async function org(): Promise<string> {
27
+ let doc: Record<string, unknown> | null;
28
+ try { doc = await v2Discovery(); } catch { doc = null; }
29
+ const ext = doc?.['extensions'];
30
+ const first = ext !== null && typeof ext === 'object' ? Object.keys(ext as Record<string, unknown>)[0] : undefined;
31
+ const o = first?.split('.')[0];
32
+ return o !== undefined && o.length > 0 ? o : 'acme';
33
+ }
34
+
35
+ function protocolTypes(): Set<string> {
36
+ const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'run-event.schema.json'), 'utf8')) as { properties: { type: { oneOf: Array<{ enum?: string[] }> } } };
37
+ return new Set(schema.properties.type.oneOf.flatMap((b) => b.enum ?? []));
38
+ }
39
+
40
+ function event(type: string): Record<string, unknown> {
41
+ return {
42
+ eventId: 'conformanceevent0123456789',
43
+ runId: 'openwop-conformance-tenant/conformancerun0123456789',
44
+ type,
45
+ payload: { anything: 'the suite does not act on it' },
46
+ timestamp: '2026-09-03T00:00:00Z',
47
+ sequence: 0,
48
+ schemaVersion: 1,
49
+ };
50
+ }
51
+
52
+ describe('v2 enum-growth-rule (RFC 0171 §A.5)', () => {
53
+ it('the envelope accepts a registered-but-unknown-to-this-suite vendor member', async () => {
54
+ const validate = v2Validator('run-event');
55
+ const type = `${await org()}.thing`;
56
+ expect(protocolTypes().has(type), req('openwop.requirement.0171.enum-growth-rule.unknown-member-accepted', DOC, `${type} MUST NOT be a member of the suite's closed protocol enum — it is the unknown member the rule is about`)).toBe(false);
57
+ const r = validate(event(type));
58
+ expect(r.ok, req('openwop.requirement.0171.enum-growth-rule.unknown-member-accepted', DOC, `a consumer MUST accept an unknown registered member: the schema's oneOf vendor branch MUST admit ${type} (${r.errors})`)).toBe(true);
59
+ const three = validate(event(`${await org()}.thing.happened`));
60
+ expect(three.ok, req('openwop.requirement.0171.enum-growth-rule.unknown-member-accepted', DOC, 'the vendor branch admits an optional third segment (<org>.<domain>.<name>)')).toBe(true);
61
+ });
62
+
63
+ it('the reserved prefix and a malformed member are rejected', () => {
64
+ const validate = v2Validator('run-event');
65
+ expect(validate(event('openwop.thing')).ok, req('openwop.requirement.0171.enum-growth-rule.reserved-rejected', DOC, 'openwop. is the only reserved prefix; a type under it that is not in the enum MUST fail')).toBe(false);
66
+ expect(validate(event('Acme.Thing')).ok, req('openwop.requirement.0171.enum-growth-rule.reserved-rejected', DOC, 'a member outside the positive pattern (upper-case) MUST fail')).toBe(false);
67
+ expect(validate(event('thing')).ok, req('openwop.requirement.0171.enum-growth-rule.reserved-rejected', DOC, 'a single-segment member MUST fail — every type is <domain>.<name> or <org>.<name>')).toBe(false);
68
+ });
69
+ });
@@ -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
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * RFC 0176 §A.2 — `era-key` (suite 2.0.0, target major 2; unaided).
3
+ *
4
+ * `eventLogSchemaVersion` is the era key: a v2 host MUST stamp `3` on every run
5
+ * it creates, the field is REQUIRED on every run snapshot
6
+ * (`schemas/v2/run-snapshot.schema.json`), and discovery advertises the value
7
+ * the host writes for new runs and nothing else (`spec/v2/core/persistence.md`
8
+ * §The era key; versioning.md §2.2). The absent-⇒-`2` half of the rule is a
9
+ * property of a store a v1 host has written and is witnessed by the seam-gated
10
+ * `v2-v1-events-translated` scenario, not here.
11
+ *
12
+ * Legs:
13
+ * 1. a run created now (`POST /runs`, the `conformance-noop` fixture) reads
14
+ * back with `eventLogSchemaVersion: 3`;
15
+ * 2. discovery's `eventLogSchemaVersion` equals the value the host wrote.
16
+ *
17
+ * @see spec/v2/core/persistence.md §The era key
18
+ * @see spec/v2/core/runs.md §getRun
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
23
+ import { v2Discovery } from '../lib/v2.js';
24
+ import { readErrorCode } from '../lib/error-envelope.js';
25
+ import { softSkip } from '../lib/soft-skip.js';
26
+ import { req } from '../lib/requirement-ids.js';
27
+
28
+ const DOC = 'spec/v2/core/persistence.md §The era key';
29
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
30
+ const V2_ERA = 3;
31
+
32
+ async function discovery(): Promise<Record<string, unknown> | null> {
33
+ try { return await v2Discovery(); } catch { return null; }
34
+ }
35
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
36
+ try { return await fn(); } catch { return null; }
37
+ }
38
+
39
+ async function freshSnapshot(): Promise<{ snapshot: Record<string, unknown> } | { reason: string }> {
40
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
41
+ if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
42
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
43
+ if (created.status !== 201 || typeof runId !== 'string') {
44
+ return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
45
+ }
46
+ const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
47
+ if (snap === null || snap.status !== 200 || !snap.json || typeof snap.json !== 'object') return { reason: `GET /runs/{runId} answered ${snap?.status ?? 'no response'} for the run just created` };
48
+ return { snapshot: snap.json as Record<string, unknown> };
49
+ }
50
+
51
+ describe('RFC 0176 §A.2 — era-key (unaided)', () => {
52
+ it('a run created now carries eventLogSchemaVersion 3 on its snapshot', async () => {
53
+ const doc = await discovery();
54
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
55
+ const s = await freshSnapshot();
56
+ if ('reason' in s) return softSkip('blocked', s.reason);
57
+ expect(
58
+ s.snapshot['eventLogSchemaVersion'],
59
+ req('openwop.requirement.0176.era-key', DOC, 'eventLogSchemaVersion is REQUIRED on every run snapshot (run-snapshot.schema.json) — the era key is never absent on a run a v2 host created'),
60
+ ).toBeDefined();
61
+ expect(
62
+ s.snapshot['eventLogSchemaVersion'],
63
+ req('openwop.requirement.0176.era-key', DOC, `a v2 host MUST stamp ${V2_ERA} on every run it creates (RFC 0176 §A.2; versioning.md §2.2) — got ${String(s.snapshot['eventLogSchemaVersion'])}`),
64
+ ).toBe(V2_ERA);
65
+ });
66
+
67
+ it('discovery advertises the value the host writes for new runs and nothing else', async () => {
68
+ const doc = await discovery();
69
+ if (!doc) return softSkip('blocked', 'discovery unreachable');
70
+ const advertised = doc['eventLogSchemaVersion'];
71
+ expect(
72
+ Number.isInteger(advertised),
73
+ req('openwop.requirement.0176.era-key.discovery', 'spec/v2/core/versioning.md §2.2', `discovery MUST advertise eventLogSchemaVersion as an integer (metadata key, capabilities.md §Metadata) — got ${String(advertised)}`),
74
+ ).toBe(true);
75
+ expect(
76
+ advertised,
77
+ req('openwop.requirement.0176.era-key.discovery', 'spec/v2/core/versioning.md §2.2', `the advertised value is the one the host writes for NEW runs: ${V2_ERA} on a v2 host (a host holds one constant for this axis, RFC 0167 §F)`),
78
+ ).toBe(V2_ERA);
79
+ const s = await freshSnapshot();
80
+ if ('reason' in s) return softSkip('blocked', `${s.reason} — the advertised value was checked, its agreement with a written run was not`);
81
+ expect(
82
+ s.snapshot['eventLogSchemaVersion'],
83
+ req('openwop.requirement.0176.era-key.discovery', DOC, 'the value stamped on a run created now MUST equal the value discovery advertises'),
84
+ ).toBe(advertised);
85
+ });
86
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * v2 — `error-registry` (suite 2.0.0; RFC 0171 §B.1–§B.2;
3
+ * `spec/v2/core/errors.md` §"The envelope", §"Retry timing").
4
+ *
5
+ * Witness class: witnessable — unaided (the `Retry-After` leg is gated on a
6
+ * rate-limited response being observed). Every error response MUST be the
7
+ * closed envelope `{ error, message, details? }` with a registered (or vendor)
8
+ * code answered at its registered HTTP status; `details.retryAfter`,
9
+ * `retryAfterMs` and `retryAfterSeconds` MUST NOT be emitted — retry timing
10
+ * lives in `Retry-After` only, which a `429 rate_limited` MUST set. The
11
+ * deliberately bad request is `GET /runs/does-not-exist` (outside the runId
12
+ * grammar, so a host may answer `400 validation_error` or `404 not_found`).
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
19
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
20
+ import { SCHEMAS_DIR } from '../lib/paths.js';
21
+ import { readErrorCode } from '../lib/error-envelope.js';
22
+ import { softSkip } from '../lib/soft-skip.js';
23
+ import { req } from '../lib/requirement-ids.js';
24
+
25
+ const DOC = 'spec/v2/core/errors.md §The envelope';
26
+ const ERRORS_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'errors.json');
27
+ const RETRY_SPELLINGS = ['retryAfter', 'retryAfterMs', 'retryAfterSeconds'];
28
+
29
+ async function discovery(): Promise<Record<string, unknown> | null> {
30
+ try { return await v2Discovery(); } catch { return null; }
31
+ }
32
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
33
+ try { return await fn(); } catch { return null; }
34
+ }
35
+
36
+ /** code → registered httpStatus, when the registry ships in this layout. */
37
+ function registryStatus(): Map<string, number> | null {
38
+ if (!existsSync(ERRORS_PATH)) return null;
39
+ const rows = (JSON.parse(readFileSync(ERRORS_PATH, 'utf8')) as { rows?: Array<{ code: string; httpStatus: number }> }).rows ?? [];
40
+ return new Map(rows.map((r) => [r.code, r.httpStatus]));
41
+ }
42
+
43
+ async function badRequest(): Promise<OpenWOPResponse | { reason: string }> {
44
+ if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
45
+ const res = await http(() => driver.get('/runs/does-not-exist'));
46
+ if (res === null) return { reason: 'GET /runs/does-not-exist unreachable (fetch failed)' };
47
+ if (res.status < 400) return { reason: `GET /runs/does-not-exist answered ${res.status} — no error envelope to observe` };
48
+ return res;
49
+ }
50
+
51
+ describe('v2 error-registry (RFC 0171 §B.1–§B.2)', () => {
52
+ it('an error response is the closed envelope with a registered code at its registered status', async () => {
53
+ const res = await badRequest();
54
+ if ('reason' in res) return softSkip('blocked', res.reason);
55
+ const r = v2Validator('error-envelope')(res.json);
56
+ expect(r.ok, req('openwop.requirement.0171.error-registry.envelope', DOC, `every error body MUST be { error, message, details? } and nothing else, with error a registered or vendor code (${r.errors})`)).toBe(true);
57
+ const code = readErrorCode(res.json);
58
+ const registry = registryStatus();
59
+ if (registry !== null && code !== undefined && registry.has(code)) {
60
+ expect(res.status, req('openwop.requirement.0171.error-registry.envelope', DOC, `a host MUST answer ${code} with its registered status ${String(registry.get(code))}`)).toBe(registry.get(code));
61
+ }
62
+ });
63
+
64
+ it('retry timing is never spelled inside details', async () => {
65
+ const res = await badRequest();
66
+ if ('reason' in res) return softSkip('blocked', res.reason);
67
+ const details = (res.json as { details?: unknown } | undefined)?.details;
68
+ const spelled = details !== null && typeof details === 'object' ? RETRY_SPELLINGS.filter((k) => k in (details as Record<string, unknown>)) : [];
69
+ expect(spelled, req('openwop.requirement.0171.error-registry.no-retry-details', 'spec/v2/core/errors.md §Retry timing', 'details.retryAfter / retryAfterMs / retryAfterSeconds are not part of v2 and a host MUST NOT emit them — Retry-After is the only retry timing')).toEqual([]);
70
+ });
71
+
72
+ it('a 429 rate_limited response carries Retry-After', async () => {
73
+ if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
74
+ // The suite does not manufacture load; it inspects whatever the probes it
75
+ // already makes return. A host that never rate-limits the suite records the
76
+ // leg as inapplicable (witnessable — gated on a host advertising limits).
77
+ const seen: OpenWOPResponse[] = [];
78
+ for (let i = 0; i < 3; i++) {
79
+ const res = await http(() => driver.get('/runs/does-not-exist'));
80
+ if (res !== null) seen.push(res);
81
+ }
82
+ const limited = seen.filter((r) => r.status === 429);
83
+ if (limited.length === 0) return softSkip('inapplicable', 'no 429 rate_limited response was observed during the suite\'s probes — the Retry-After leg is gated on a rate-limited response');
84
+ for (const r of limited) {
85
+ expect(readErrorCode(r.json), req('openwop.requirement.0171.error-registry.retry-after', 'spec/v2/core/errors.md §Retry timing', 'a 429 MUST carry the registered code rate_limited')).toBe('rate_limited');
86
+ expect(r.headers.get('retry-after'), req('openwop.requirement.0171.error-registry.retry-after', 'spec/v2/core/errors.md §Retry timing', 'a 429 rate_limited response MUST set Retry-After')).not.toBeNull();
87
+ }
88
+ });
89
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * v2 — `event-type-closed` (suite 2.0.0; RFC 0171 §A.1, §A.5;
3
+ * `spec/v2/core/events.md` §"The envelope", §"Types").
4
+ *
5
+ * Witness class: witnessable — unaided. Every event a host emits on a run's
6
+ * poll response carries a `type` that is EITHER a member of the closed enum in
7
+ * `schemas/v2/run-event.schema.json` OR a vendor type matching the positive
8
+ * `<org>.<name>` pattern whose org the host itself advertises under
9
+ * `extensions` — a producer MUST NOT emit an unregistered member. Each event is
10
+ * validated as a whole `RunEventDoc`. The run is the `conformance-noop`
11
+ * fixture, awaited to a terminal status so the log holds the run transitions.
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import { readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
18
+ import { v2Discovery, v2Validator } from '../lib/v2.js';
19
+ import { SCHEMAS_DIR } from '../lib/paths.js';
20
+ import { readErrorCode } from '../lib/error-envelope.js';
21
+ import { softSkip } from '../lib/soft-skip.js';
22
+ import { req } from '../lib/requirement-ids.js';
23
+
24
+ const DOC = 'spec/v2/core/events.md §Types';
25
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
26
+ const VENDOR = /^(?!openwop\.)[a-z][a-z0-9]*(-[a-z0-9]+)*\.[a-z][a-z0-9]*(-[a-z0-9]+)*(\.[a-z][a-z0-9]*(-[a-z0-9]+)*)?$/;
27
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
28
+ const TIMEOUT_MS = Number(process.env['OPENWOP_LIFECYCLE_TIMEOUT_MS'] ?? 10_000);
29
+
30
+ async function discovery(): Promise<Record<string, unknown> | null> {
31
+ try { return await v2Discovery(); } catch { return null; }
32
+ }
33
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
34
+ try { return await fn(); } catch { return null; }
35
+ }
36
+
37
+ /** The closed protocol enum, read from the generated schema. */
38
+ function protocolTypes(): Set<string> {
39
+ const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'v2', 'run-event.schema.json'), 'utf8')) as { properties: { type: { oneOf: Array<{ enum?: string[] }> } } };
40
+ return new Set(schema.properties.type.oneOf.flatMap((b) => b.enum ?? []));
41
+ }
42
+
43
+ /** Orgs the host registers under `extensions.<org>.<name>`. */
44
+ function advertisedOrgs(doc: Record<string, unknown>): Set<string> {
45
+ const ext = doc['extensions'];
46
+ const keys = ext !== null && typeof ext === 'object' ? Object.keys(ext as Record<string, unknown>) : [];
47
+ return new Set(keys.map((k) => k.split('.')[0] ?? ''));
48
+ }
49
+
50
+ async function terminalEvents(): Promise<{ events: unknown[]; doc: Record<string, unknown> } | { reason: string }> {
51
+ const doc = await discovery();
52
+ if (!doc) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
53
+ const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
54
+ if (created === null) return { reason: 'POST /runs unreachable (fetch failed)' };
55
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
56
+ if (created.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim() };
57
+ const deadline = Date.now() + TIMEOUT_MS;
58
+ while (Date.now() < deadline) {
59
+ const snap = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
60
+ if (snap !== null && TERMINAL.has(String((snap.json as { status?: unknown } | undefined)?.status))) break;
61
+ await new Promise((r) => setTimeout(r, 250));
62
+ }
63
+ const poll = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
64
+ if (poll === null || poll.status !== 200) return { reason: `GET /runs/{runId}/events/poll answered ${poll?.status ?? 'no response'}` };
65
+ const events = (poll.json as { events?: unknown } | undefined)?.events;
66
+ if (!Array.isArray(events) || events.length === 0) return { reason: 'the poll returned no events for a run driven to terminal status — the log is not observable' };
67
+ return { events, doc };
68
+ }
69
+
70
+ describe('v2 event-type-closed (RFC 0171 §A.1, §A.5)', () => {
71
+ it('every emitted type is a registered protocol type or a vendor type under an advertised org', async () => {
72
+ const t = await terminalEvents();
73
+ if ('reason' in t) return softSkip('blocked', t.reason);
74
+ const enumSet = protocolTypes();
75
+ const orgs = advertisedOrgs(t.doc);
76
+ for (const ev of t.events) {
77
+ const type = String((ev as { type?: unknown }).type);
78
+ const registered = enumSet.has(type);
79
+ const vendor = !registered && VENDOR.test(type) && orgs.has(type.split('.')[0] ?? '');
80
+ expect(registered || vendor, req('openwop.requirement.0171.event-type-closed.type', DOC, `event type ${type} MUST be a member of the closed enum or a vendor type <org>.<name> whose org the host advertises under extensions (a producer MUST NOT emit an unregistered member; openwop. is reserved)`)).toBe(true);
81
+ }
82
+ });
83
+
84
+ it('every emitted event validates as a RunEventDoc', async () => {
85
+ const t = await terminalEvents();
86
+ if ('reason' in t) return softSkip('blocked', t.reason);
87
+ const validate = v2Validator('run-event');
88
+ let previous = -1;
89
+ for (const ev of t.events) {
90
+ const r = validate(ev);
91
+ expect(r.ok, req('openwop.requirement.0171.event-type-closed.envelope', 'spec/v2/core/events.md §The envelope', `every event MUST validate against schemas/v2/run-event.schema.json — eventId, runId, type, payload, timestamp, sequence, schemaVersion REQUIRED; closed (${r.errors})`)).toBe(true);
92
+ const seq = (ev as { sequence?: unknown }).sequence;
93
+ expect(typeof seq === 'number' && seq > previous, req('openwop.requirement.0171.event-type-closed.envelope', 'spec/v2/core/events.md §The envelope', 'sequence is the one ordering field: integer ≥ 0, strictly increasing per run')).toBe(true);
94
+ previous = typeof seq === 'number' ? seq : previous;
95
+ }
96
+ });
97
+ });