diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17998ff..35e50fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,7 @@ jobs: - name: Test run: dotnet test --no-build --configuration Release --verbosity normal - - name: Test against Eve 0.27.6 + - name: Test against the pinned Eve fixture env: EVE_PROBE_NO_BUILD: 1 run: npm run test:client --prefix test/fixtures/eve-agent diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bf1197..9eec98c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -148,7 +148,7 @@ jobs: - name: Test run: dotnet test --configuration Release --no-build --verbosity normal - - name: Test against Eve 0.27.6 + - name: Test against the pinned Eve fixture env: EVE_PROBE_NO_BUILD: 1 run: npm run test:client --prefix test/fixtures/eve-agent diff --git a/AGENTS.md b/AGENTS.md index 6a5b7ae..a4c4816 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ and structured-output support without using the TypeScript SDK. - `EveStreamFollower` mirrors upstream absolute-index reconnect and idle-budget behavior. - Preview event and inspection payloads retain raw `JsonElement` values so unknown upstream fields and event types remain available. -- The compatibility baseline is Vercel eve 0.27.6, stream protocol version 19. +- The compatibility baseline is Vercel eve 0.29.4, stream protocol version 20. - `test/fixtures/eve-agent` is a pinned, deterministic real Eve server used by the compatibility probe under `tests/NexusLabs.Eve.CompatibilityProbe`. - `version.json` is the only release-version source; package versions come from NBGV. diff --git a/CHANGELOG.md b/CHANGELOG.md index 61858df..cf50c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,25 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - `EveStreamOptions.Follow` for bounded catch-up reads that stop at the durable stream tail observed when the stream opens, using the `includeTailIndex=1` query parameter and the `x-eve-stream-tail-index` response header. +- `EveStreamEventMetadata.Id` projecting the stable `evt_`-prefixed identifier that + message-stream protocol `20` stamps on every persisted event, plus + `EveStreamEventDeduplicator` for dropping re-delivered events across reconnects and + rewinds. Events persisted under protocol `19` report `null` and are always admitted. +- `EveInputRequest.Kind` and `EveInputRequest.RawKind`, projecting eve's framework-owned + input-request discriminator through the `EveInputRequestKind` enum so `question`, + `tool-approval`, and `session-limit` requests are routed by contract instead of by + option shape. A server that predates the discriminator reports `Unknown` with a `null` + raw value. ### Changed -- The compatibility reference moved to eve `0.27.6` (message-stream protocol `19`). +- The compatibility reference moved to eve `0.29.4` (message-stream protocol `20`). The + pinned CI fixture runs that release, and the compatibility probe now verifies stamped + event identifiers, a real bounded catch-up read against the durable tail header, and an + approval-gated human-input pause end to end. - Accepted session IDs and continuation tokens are persisted in `EveSession.State` as soon as `SendAsync` returns, before the response stream is consumed. -- Non-protected per-request headers now override client-level values, matching eve `0.27.6`. +- Non-protected per-request headers now override client-level values, matching upstream eve. Authentication-owned and explicitly protected headers remain authoritative by default and require an allowlisted, dedicated per-call override. diff --git a/README.md b/README.md index 42b44bd..731eac4 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ health and agent inspection, authentication, durable sessions, human-input respo cooperative cancellation, session reset, NDJSON streaming, reconnect-by-index, attachments, and structured output. -The initial compatibility target is Vercel `eve` **0.27.6** at commit -`05f348023d4268c974c225c1189a283ace20b742`, whose message stream protocol is version -**19**. eve is still a preview, so pin and test compatible versions before upgrading. +The initial compatibility target is Vercel `eve` **0.29.4** at commit +`85c1dd7a647a04cc1bd74879ba8d27a3ba0bdd9d`, whose message stream protocol is version +**20**. eve is still a preview, so pin and test compatible versions before upgrading. ## Prerequisites diff --git a/docs/attachments-and-input.md b/docs/attachments-and-input.md index 3a7b785..8b55861 100644 --- a/docs/attachments-and-input.md +++ b/docs/attachments-and-input.md @@ -67,7 +67,7 @@ distinguishes them: |---|---|---| | `Question`, `ToolApproval`, `SessionLimit` | matching wire value | A modelled request kind | | `Unknown` | the wire value | A newer eve emitted a kind this package does not model | -| `Unknown` | `null` | The server predates the discriminator, such as eve `0.27.6` | +| `Unknown` | `null` | The server predates the discriminator, such as eve `0.27.6` or earlier | A `kind` that is present but not a string is a malformed request and throws `EveProtocolException` rather than being reported as a legacy server. diff --git a/docs/authentication.md b/docs/authentication.md index c6d3599..10606b7 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -142,7 +142,7 @@ credentials, even when that name is allowlisted. ## Migration Existing consumers keep authentication-authoritative behavior by default. To opt into the -eve 0.27.6 identity-forwarding use case: +upstream eve identity-forwarding use case: 1. Identify the exact protected header that must be replaceable. 2. Add only that name to `AllowedProtectedHeaderOverrides`. diff --git a/docs/compatibility.md b/docs/compatibility.md index 6c22b0d..0c062fa 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -6,7 +6,8 @@ description: Understand supported eve versions, stream protocol compatibility, a | NexusLabs.Eve | Reference eve | Stream protocol | Status | |---|---:|---:|---| -| 0.1.x | 0.27.6 | 19 | Primary compatibility target | +| 0.1.x | 0.29.4 | 20 | Primary compatibility target | +| 0.1.x | 0.27.6 | 19 | Previous baseline; tolerated, not gated by CI | | 0.1.x | 0.24.6 | 19 | End-to-end verified with `bg-eve` | eve remains preview software. Package upgrades should therefore validate both: @@ -14,12 +15,17 @@ eve remains preview software. Package upgrades should therefore validate both: 1. The public HTTP route and body contracts. 2. The durable message-stream protocol version and event shapes. -The repository contains a pinned eve `0.27.6` fixture with a deterministic +The repository contains a pinned eve `0.29.4` fixture with a deterministic model. CI builds the real server and verifies health, info, text turns, -attachment staging, streaming, cooperative cancellation, and session reset -through the C# client. +attachment staging, streaming, bounded catch-up reads, cooperative cancellation, +approval-gated human input, and session reset through the C# client. -Upstream eve 0.27.6 lets generic per-request headers replace authentication. +The client stays readable against protocol 19 servers: durable event +identifiers and input-request discriminators are both projected as absent +rather than causing a failure. That path is covered by contract tests, not by +the pinned fixture. + +Upstream eve lets generic per-request headers replace authentication. NexusLabs.Eve requires an explicit client allowlist and dedicated per-call override for protected headers so existing generic header bags cannot silently replace credentials. @@ -31,9 +37,9 @@ instead of causing deserialization failure. Stream protocol version 20 stamps every persisted event with a stable `evt_`-prefixed identifier. `EveStreamEvent.Metadata.Id` projects it when present and reports `null` for events persisted under earlier protocol -versions, which cannot be deduplicated. eve `0.27.6` emits protocol version 19, -so the pinned compatibility probe asserts that durable timestamps still arrive -while identifiers stay absent. +versions, which cannot be deduplicated. The compatibility probe asserts that +the pinned server stamps a well-formed identifier on every event of a turn and +never repeats one. ## Upstream parity radar @@ -50,11 +56,13 @@ deliberately not committed. Bounded catch-up reads (`EveStreamOptions.Follow = false`) depend on the `includeTailIndex=1` stream query parameter and the `x-eve-stream-tail-index` -response header. eve `0.27.6` accepts the query parameter but does not report the -header, so bounded reads against that baseline fail with `EveProtocolException` -instead of silently degrading to a live follow. The compatibility probe asserts -both halves of that contract and switches to verifying a real bounded read once -the pinned server reports the header. +response header. The pinned server reports the header, so the compatibility +probe verifies a real bounded read: the first request asks for the tail, +reconnects never re-request it, and the read stops exactly at the durable bound +while advancing the stored cursor. A server that omits the header, or reports a +malformed or out-of-range value, fails with `EveProtocolException` instead of +silently degrading to a live follow. eve `0.27.6` accepted the query parameter +without reporting the header, so bounded reads against that release fail. ## Input request kinds @@ -63,7 +71,7 @@ eve stamps each human-input request with a framework-owned `kind` of `question`, `EveInputRequest.RawKind` preserves the wire value, so an unmodelled future kind stays inspectable instead of being misclassified from its option shape. -eve `0.27.6` predates the discriminator and omits it, which reports -`EveInputRequestKind.Unknown` with a `null` raw value. The compatibility probe -drives a real approval-gated tool against the pinned fixture and asserts that -behavior end to end. +The compatibility probe drives a real approval-gated tool against the pinned +fixture, asserts the request arrives as `tool-approval`, answers it, and +verifies the turn resumes. A server that predates the discriminator reports +`EveInputRequestKind.Unknown` with a `null` raw value. diff --git a/docs/index.md b/docs/index.md index 68ec682..24b7709 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ part of the .NET API. ## Compatibility -The initial release targets eve `0.27.6`, message-stream protocol version `19`. +The initial release targets eve `0.29.4`, message-stream protocol version `20`. The client has also completed an end-to-end session against a real eve `0.24.6` application using the same protocol version. diff --git a/docs/streaming.md b/docs/streaming.md index 2ba0ab6..e46776e 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -32,7 +32,6 @@ retried step is not a replay: it is emitted again under a new identifier. Events persisted before protocol version 20 carry no identifier and report `null`, so they cannot be deduplicated. eve `0.27.6` emits protocol version 19 and never stamps one. - `EveStreamEventDeduplicator` encodes that contract, so a caller that resumes a stream can drop events it already processed: @@ -108,7 +107,7 @@ Bounded reads require a nonnegative effective start cursor, so combining `Follow = false` with a tail-relative `StartIndex` throws `ArgumentOutOfRangeException`. A server that omits the tail header, or reports a malformed or out-of-range value, throws `EveProtocolException`; eve `0.27.6` -does not report the header yet. +never reported the header, so bounded reads against that release fail. ## Bound individual events diff --git a/src/NexusLabs.Eve/EveProtocol.cs b/src/NexusLabs.Eve/EveProtocol.cs index d2d8e67..6dfd035 100644 --- a/src/NexusLabs.Eve/EveProtocol.cs +++ b/src/NexusLabs.Eve/EveProtocol.cs @@ -8,12 +8,12 @@ public static class EveProtocol /// /// Gets the upstream TypeScript package version used as the compatibility reference. /// - public const string ReferenceEveVersion = "0.27.6"; + public const string ReferenceEveVersion = "0.29.4"; /// /// Gets the durable message-stream protocol version used by the reference client. /// - public const string MessageStreamVersion = "19"; + public const string MessageStreamVersion = "20"; /// /// Gets the media type returned by eve session streams. diff --git a/test/fixtures/eve-agent/package-lock.json b/test/fixtures/eve-agent/package-lock.json index 5b95632..909bad2 100644 --- a/test/fixtures/eve-agent/package-lock.json +++ b/test/fixtures/eve-agent/package-lock.json @@ -6,8 +6,8 @@ "": { "name": "nexuslabs-eve-compatibility-agent", "dependencies": { - "ai": "7.0.34", - "eve": "0.27.6", + "ai": "7.0.42", + "eve": "0.29.4", "just-bash": "3.1.0" }, "engines": { @@ -15,13 +15,13 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "4.0.26", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.26.tgz", - "integrity": "sha512-b/nc3COKtk8IxzgcCi418IoZFky/Bw+Jg8+J0/SBBnu/mOXA4bkIKCu81coEtJAWuH2g5Fvvsa0ar7EpmzNWTw==", + "version": "4.0.32", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.32.tgz", + "integrity": "sha512-U82ZbFEQY80YTyzOEI0jrCjV4Q52uN7/ln/KyI1AvSNR/IX3XwePOfsQWOfDhvmqXkb3TcYbVzWr4p85msKgOw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.3", - "@ai-sdk/provider-utils": "5.0.12", + "@ai-sdk/provider": "4.0.4", + "@ai-sdk/provider-utils": "5.0.15", "@vercel/oidc": "3.2.0" }, "engines": { @@ -32,9 +32,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.3.tgz", - "integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.4.tgz", + "integrity": "sha512-tbHKNLirllUNF3ZlkCsXnwab2ZV1Sl4b1H/Cp9ruCce15IBmskE8Gwkk0yo9xDWY+jho2of7lVXtwSsyrq7cwQ==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -44,15 +44,16 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.12.tgz", - "integrity": "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==", + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.15.tgz", + "integrity": "sha512-dnDM4/fS17qO+D7LxVU4003V1+8ZDEWgG7rPhvcDNNFs269qLx+Jnm2mTf72ejyjdzu+f0J+rKNSLXWjZWzxwA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider": "4.0.4", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", - "eventsource-parser": "^3.0.8" + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" }, "engines": { "node": ">=22" @@ -525,14 +526,14 @@ "license": "Apache-2.0" }, "node_modules/ai": { - "version": "7.0.34", - "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.34.tgz", - "integrity": "sha512-jDqclWYqPGFKcUG4CQiKcBiwi5XqVMqvYy6eJdujVpvE1SDoB6j7RtjrGYz3Bn5B1dzTj1StAlrOebY//WK9/Q==", + "version": "7.0.42", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.42.tgz", + "integrity": "sha512-61AEmo8DynuQmfxt1yOJHLJRonCnzK5baFvN6bsDiuOTt/qYk9ZexpbG4aRv3F6rx418BV6Eyblj6fA7UcH5vQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "4.0.26", - "@ai-sdk/provider": "4.0.3", - "@ai-sdk/provider-utils": "5.0.12" + "@ai-sdk/gateway": "4.0.32", + "@ai-sdk/provider": "4.0.4", + "@ai-sdk/provider-utils": "5.0.15" }, "engines": { "node": ">=22" @@ -813,12 +814,13 @@ } }, "node_modules/eve": { - "version": "0.27.6", - "resolved": "https://registry.npmjs.org/eve/-/eve-0.27.6.tgz", - "integrity": "sha512-t2Xr/nlVGc0+Fy9fav8p/CJrwMSQM0zyuOGY1F1XapN0UFssGYBGa6Ml/Q5OnowvWwsGH4m0Tf2QAccayVCvtw==", + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/eve/-/eve-0.29.4.tgz", + "integrity": "sha512-EwOmL37l+Iuu7Umno7at3flFpMs4AtT9cg1J4dt7EZ8ZdxaCvGXwvKGwiulMkG8oXkMQ5CNhxMi2ruHJwrtpwQ==", "license": "Apache-2.0", "dependencies": { - "nitro": "3.0.260610-beta" + "nitro": "3.0.260610-beta", + "undici": "8.9.0" }, "bin": { "eve": "bin/eve.js" @@ -828,7 +830,7 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "ai": "^7.0.34", + "ai": "^7.0.38", "braintrust": "^3.0.0", "just-bash": "^3.0.0", "microsandbox": "^0.5.0" @@ -848,6 +850,15 @@ } } }, + "node_modules/eve/node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/eventsource-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", @@ -1738,6 +1749,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", diff --git a/test/fixtures/eve-agent/package.json b/test/fixtures/eve-agent/package.json index 93ad46c..bee6f4c 100644 --- a/test/fixtures/eve-agent/package.json +++ b/test/fixtures/eve-agent/package.json @@ -8,8 +8,8 @@ "test:client": "node run-client-probe.mjs" }, "dependencies": { - "ai": "7.0.34", - "eve": "0.27.6", + "ai": "7.0.42", + "eve": "0.29.4", "just-bash": "3.1.0" }, "engines": { diff --git a/test/fixtures/eve-agent/run-client-probe.mjs b/test/fixtures/eve-agent/run-client-probe.mjs index ce18003..4aaf10f 100644 --- a/test/fixtures/eve-agent/run-client-probe.mjs +++ b/test/fixtures/eve-agent/run-client-probe.mjs @@ -1,12 +1,18 @@ import { once } from "node:events"; import { spawn } from "node:child_process"; -import { rm } from "node:fs/promises"; +import { readFile, rm } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; const fixtureDirectory = path.dirname(fileURLToPath(import.meta.url)); const repositoryRoot = path.resolve(fixtureDirectory, "..", "..", ".."); const eveBin = path.join(fixtureDirectory, "node_modules", "eve", "bin", "eve.js"); +const eveVersion = JSON.parse( + await readFile( + path.join(fixtureDirectory, "node_modules", "eve", "package.json"), + "utf8", + ), +).version; const probeProject = path.join( repositoryRoot, "tests", @@ -79,7 +85,7 @@ try { if (code !== 0) { throw new Error(`The C# compatibility probe exited with code ${code}.`); } - console.log("Eve 0.27.6 compatibility probe passed."); + console.log(`Eve ${eveVersion} compatibility probe passed.`); } finally { await stopProcess(server); } diff --git a/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs b/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs index 1f72c9b..baa2d54 100644 --- a/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs +++ b/tests/NexusLabs.Eve.CompatibilityProbe/Program.cs @@ -142,46 +142,41 @@ string? observedTailIndex = catchUpRequests[0].TailIndex; if (observedTailIndex is null) { - // eve 0.27.6, the pinned compatibility baseline, does not report the durable tail index. - if (catchUpFailure is null) - { - throw new InvalidOperationException( - "The Eve fixture omitted x-eve-stream-tail-index, but the bounded read did not fail."); - } + throw new InvalidOperationException( + $"eve {EveProtocol.ReferenceEveVersion} omitted the x-eve-stream-tail-index response " + + "header, so a bounded catch-up read cannot be verified."); } -else + +if (catchUpFailure is not null) { - if (catchUpFailure is not null) - { - throw new InvalidOperationException( - $"The Eve fixture reported tail index '{observedTailIndex}', " + - $"but the bounded read failed: {catchUpFailure.Message}"); - } + throw new InvalidOperationException( + $"The Eve fixture reported tail index '{observedTailIndex}', " + + $"but the bounded read failed: {catchUpFailure.Message}"); +} - if (!int.TryParse( - observedTailIndex, - NumberStyles.AllowLeadingSign, - CultureInfo.InvariantCulture, - out int tailIndex) - || tailIndex < 0) - { - throw new InvalidOperationException( - $"The Eve fixture reported an invalid tail index: '{observedTailIndex}'."); - } +if (!int.TryParse( + observedTailIndex, + NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, + out int tailIndex) + || tailIndex < 0) +{ + throw new InvalidOperationException( + $"The Eve fixture reported an invalid tail index: '{observedTailIndex}'."); +} - if (catchUpEvents.Count != tailIndex + 1) - { - throw new InvalidOperationException( - $"The bounded catch-up read returned {catchUpEvents.Count} events " + - $"for tail index {tailIndex}."); - } +if (catchUpEvents.Count != tailIndex + 1) +{ + throw new InvalidOperationException( + $"The bounded catch-up read returned {catchUpEvents.Count} events " + + $"for tail index {tailIndex}."); +} - if (catchUpSession.State.StreamIndex != catchUpEvents.Count) - { - throw new InvalidOperationException( - "The bounded catch-up read did not advance the session cursor: " + - $"{catchUpSession.State.StreamIndex} of {catchUpEvents.Count} events."); - } +if (catchUpSession.State.StreamIndex != catchUpEvents.Count) +{ + throw new InvalidOperationException( + "The bounded catch-up read did not advance the session cursor: " + + $"{catchUpSession.State.StreamIndex} of {catchUpEvents.Count} events."); } EveSession approvalSession = client.CreateSession(); @@ -203,10 +198,8 @@ EveInputRequest approvalRequest = approvalOutcome.InputRequests[0]; -// eve 0.27.6, the pinned compatibility baseline, predates the framework-owned discriminator. -// A newer fixture stamps 'tool-approval' here, which flips both assertions. -if (approvalRequest.RawKind is not null - || approvalRequest.Kind != EveInputRequestKind.Unknown) +if (approvalRequest.RawKind != "tool-approval" + || approvalRequest.Kind != EveInputRequestKind.ToolApproval) { throw new InvalidOperationException( $"eve {EveProtocol.ReferenceEveVersion} reported input request kind " + @@ -309,15 +302,12 @@ static void RequireDurableEventEnvelope(EveTurnOutcome outcome, string operation $"The {operation} produced '{streamEvent.Type}' without a durable timestamp."); } - // The pinned eve 0.27.6 baseline emits stream protocol 19 and stamps no event identifier. - // Protocol 20 adds one, so this assertion flips to requiring an 'evt_' identifier when the - // fixture advances. - if (metadata.Id is not null) + if (metadata.Id is not string identifier || !IsEventIdentifier(identifier)) { throw new InvalidOperationException( - $"The {operation} produced '{streamEvent.Type}' with identifier '{metadata.Id}', " + - $"but eve {EveProtocol.ReferenceEveVersion} emits stream protocol " + - $"{EveProtocol.MessageStreamVersion}, which stamps none."); + $"The {operation} produced '{streamEvent.Type}' with durable identifier " + + $"'{metadata.Id ?? ""}', which is not an eve stream protocol " + + $"{EveProtocol.MessageStreamVersion} event id."); } if (deduplicator.Admit(streamEvent)) @@ -326,10 +316,41 @@ static void RequireDurableEventEnvelope(EveTurnOutcome outcome, string operation } } - if (admitted != outcome.Events.Count || deduplicator.Count != 0) + if (admitted != outcome.Events.Count) { throw new InvalidOperationException( - $"The {operation} dropped identifier-less events: admitted {admitted} of " + - $"{outcome.Events.Count} while remembering {deduplicator.Count} identifiers."); + $"The {operation} repeated a durable identifier: admitted {admitted} of " + + $"{outcome.Events.Count} events."); } + + if (deduplicator.Count != outcome.Events.Count) + { + throw new InvalidOperationException( + $"The {operation} remembered {deduplicator.Count} identifiers for " + + $"{outcome.Events.Count} events."); + } +} + +// Mirrors the upstream shape check: the 'evt_' prefix followed by a Crockford base32 ULID. +static bool IsEventIdentifier(string value) +{ + const string prefix = "evt_"; + const int ulidLength = 26; + const string crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + if (!value.StartsWith(prefix, StringComparison.Ordinal) + || value.Length != prefix.Length + ulidLength) + { + return false; + } + + foreach (char character in value.AsSpan(prefix.Length)) + { + if (!crockfordAlphabet.Contains(character, StringComparison.Ordinal)) + { + return false; + } + } + + return true; }