From 002e2c54c14a0a927af33935be95cfc785b06a16 Mon Sep 17 00:00:00 2001 From: zhanghanduo Date: Tue, 8 Sep 2026 17:45:00 +0800 Subject: [PATCH] fix(events): separate opaque ids from cursor ordinals --- CHANGELOG.md | 16 ++++++++++++++++ agent_core/types.py | 8 +++++++- docs/agent-bus-boundary.md | 5 +++-- pyproject.toml | 2 +- tests/test_agent_comm_store_contract.py | 20 ++++++++++++++++++++ tests/test_runtime_foundation.py | 4 +++- uv.lock | 2 +- 7 files changed, 51 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e8e6b..b98c7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ the GitHub Release body, so a release with no entry here fails. Versioning follows [docs/versioning.md](docs/versioning.md). +## [0.9.1] - 2026-09-08 + +### Fixed + +- `new_event_id()` now guarantees that its opaque 16-character lowercase-hex + value contains an alphabetic namespace marker. Previously it returned the + first 16 characters of a UUID4. Because the UUID version nibble is always the + decimal digit `4`, the whole value was accidentally decimal with probability + `(10/16)^15` (about 1 in 1,153). `AgentComm.event_ordinal()` then accepted that + opaque id as the legacy decimal-store fallback and advanced its in-process + cursor to a huge, non-monotonic value instead of raising + `EventStoreContractError`. The new shape remains 16 lowercase hex characters + and retains the same 60 random bits, but its namespace can no longer overlap + decimal store ordinals. A deterministic all-decimal UUID-source regression + test replaces the probabilistic coverage that made the contract test flaky. + ## [0.9.0] - 2026-09-06 ### Fixed diff --git a/agent_core/types.py b/agent_core/types.py index 5147bcf..4d31850 100644 --- a/agent_core/types.py +++ b/agent_core/types.py @@ -20,7 +20,13 @@ def new_task_id() -> TaskId: def new_event_id() -> EventId: - return EventId(uuid4().hex[:16]) + raw = uuid4().hex + # Keep the historical 16-character lowercase-hex shape while making the + # opaque-id namespace disjoint from decimal store ordinals. UUID4's + # version nibble at index 12 is fixed to ``4``; replacing that non-random + # nibble with an alphabetic marker preserves the original 60 random bits + # and guarantees ``event_ordinal()`` cannot mistake this id for a cursor. + return EventId(f"e{raw[:12]}{raw[13:16]}") def new_session_id() -> SessionId: diff --git a/docs/agent-bus-boundary.md b/docs/agent-bus-boundary.md index 4b84235..f6993d6 100644 --- a/docs/agent-bus-boundary.md +++ b/docs/agent-bus-boundary.md @@ -51,8 +51,9 @@ retained event: `KernelEvent.seq`, a monotonically increasing integer, or a decimal `KernelEvent.id` for stores whose integer primary key already serves as the id. `EventReader`'s `after_id` cursor is expressed in that ordinal, and it — not `timestamp` — defines the order. `EventId` is deliberately an opaque -string, so `types.new_event_id()` (uuid4 hex: neither numeric nor monotonic) -cannot order a cursor; a store that stamps only that fails +string, so `types.new_event_id()` (16-character uuid4-derived hex with a +guaranteed alphabetic namespace marker: neither numeric nor monotonic) cannot +order a cursor; a store that stamps only that fails `AgentComm.consume` with `EventStoreContractError`, and `AgentBus` logs the lost durable-recovery capability at ERROR while still degrading to the in-memory path. `tests/test_agent_comm_store_contract.py` is the executable diff --git a/pyproject.toml b/pyproject.toml index 065d0c9..e61789e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "apodex-agent-core" -version = "0.9.0" +version = "0.9.1" description = "Shared, product-neutral runtime primitives for Apodex agents" readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_agent_comm_store_contract.py b/tests/test_agent_comm_store_contract.py index e1504b1..2a58c74 100644 --- a/tests/test_agent_comm_store_contract.py +++ b/tests/test_agent_comm_store_contract.py @@ -24,6 +24,7 @@ import pytest +from agent_core import types from agent_core.components.agent_bus.agent_comm import ( AgentComm, DeliveryMode, @@ -234,6 +235,25 @@ def test_default_event_id_generator_cannot_order_a_cursor(): event_ordinal(event) +def test_all_decimal_uuid_source_still_produces_an_opaque_event_id(monkeypatch): + """The opaque-id namespace must not overlap decimal store ordinals.""" + + class _AllDecimalUUID: + hex = "12345678901234567890123456789012" + + monkeypatch.setattr(types, "uuid4", _AllDecimalUUID) + + event_id = types.new_event_id() + + assert event_id == "e123456789012456" + assert not event_id.isdecimal() + event = KernelEvent( + task_id=TaskId("t1"), event_type=EventType.AGENT_MESSAGE, id=event_id, + ) + with pytest.raises(EventStoreContractError): + event_ordinal(event) + + # ────────────────────────────────────────────────────────────────────────── # The store protocol: which shape of ``append`` AgentComm requires # ────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_runtime_foundation.py b/tests/test_runtime_foundation.py index 95dd479..7be5849 100644 --- a/tests/test_runtime_foundation.py +++ b/tests/test_runtime_foundation.py @@ -35,7 +35,9 @@ def test_identity_shapes_and_string_event_contract() -> None: - assert len(new_event_id()) == 16 + event_id = new_event_id() + assert len(event_id) == 16 + assert not event_id.isdecimal() assert len(new_session_id()) == 12 assert len(new_prompt_id()) == 12 assert len(new_step_id()) == 10 diff --git a/uv.lock b/uv.lock index 74c0d0e..3d5cbca 100644 --- a/uv.lock +++ b/uv.lock @@ -50,7 +50,7 @@ wheels = [ [[package]] name = "apodex-agent-core" -version = "0.9.0" +version = "0.9.1" source = { editable = "." } dependencies = [ { name = "anthropic", extra = ["bedrock"] },