From b15924e697a373faa07a319082829d230dd1016e Mon Sep 17 00:00:00 2001 From: Gregory Zak Date: Fri, 17 Jul 2026 11:12:33 -0700 Subject: [PATCH 1/2] feat(client): add caller_name to audit_tool_call (#2912) tool_type on AuditToolCallRequest was misleadingly named -- every real caller (claude_code/codex/cursor/openclaw) used it to identify which client made the call, not any property of the tool. Add caller_name as the correctly-named field; tool_type stays as a deprecated input fallback (not removed) during the deprecation window, mirroring the platform-side resolution added in getaxonflow/axonflow-enterprise#2953 (caller_name if supplied -> legacy tool_type if supplied -> default). Implements getaxonflow/axonflow-enterprise#2912, sub-issue of epic #2905. Signed-off-by: Gregory Zak --- .lint_baselines/falsey_clobber.json | 14 +- CHANGELOG.md | 15 ++ axonflow/client.py | 10 +- axonflow/types.py | 12 +- runtime-e2e/caller_name_audit/README.md | 63 ++++++++ runtime-e2e/caller_name_audit/test.py | 202 ++++++++++++++++++++++++ tests/fixtures/wire_shape_baseline.json | 7 + tests/test_audit_tool_call.py | 118 ++++++++++++++ 8 files changed, 431 insertions(+), 10 deletions(-) create mode 100644 runtime-e2e/caller_name_audit/README.md create mode 100644 runtime-e2e/caller_name_audit/test.py diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index c710deb..54b0cc1 100644 --- a/.lint_baselines/falsey_clobber.json +++ b/.lint_baselines/falsey_clobber.json @@ -27,17 +27,17 @@ "axonflow/client.py:1669:37", "axonflow/client.py:1710:18", "axonflow/client.py:1768:37", - "axonflow/client.py:2286:24", - "axonflow/client.py:2307:33", - "axonflow/client.py:2308:31", - "axonflow/client.py:2320:25", - "axonflow/client.py:2381:28", - "axonflow/client.py:2422:69", + "axonflow/client.py:2292:24", + "axonflow/client.py:2313:33", + "axonflow/client.py:2314:31", + "axonflow/client.py:2326:25", + "axonflow/client.py:2387:28", + "axonflow/client.py:2428:69", "axonflow/client.py:300:14", "axonflow/client.py:305:24", "axonflow/client.py:306:20", "axonflow/client.py:529:44", - "axonflow/client.py:6463:25", + "axonflow/client.py:6469:25", "axonflow/client.py:845:20", "axonflow/client.py:931:20", "axonflow/execution.py:205:19", diff --git a/CHANGELOG.md b/CHANGELOG.md index d930cae..87871e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and tag v{X.Y.Z}. The release workflow's preflight checks the section header matches the tag. --> +## [Unreleased] + +### Added + +- **`AuditToolCallRequest.caller_name`** (getaxonflow/axonflow-enterprise#2912, + sub-issue of epic #2905) — identifies which client made a non-LLM tool + call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). Replaces the + misleadingly-named `tool_type` field, which every real caller actually + used to identify the calling client rather than any property of the + tool. `tool_type` is kept as a deprecated input fallback (not removed): + the server resolves `caller_name` if supplied, else the legacy + `tool_type`, else a default. See + `runtime-e2e/caller_name_audit/` for the real-stack proof that + `caller_name` reaches `policy_details.caller_name` on the audit row. + ## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes Hostile-testing sweep ahead of the BukuWarung integration diff --git a/axonflow/client.py b/axonflow/client.py index c138b01..d4095fd 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -2163,7 +2163,7 @@ async def audit_tool_call( trail for governance and compliance. Args: - request: Tool call details including tool name, type, input/output, + request: Tool call details including tool name, caller, input/output, and associated workflow/step information. Returns: @@ -2173,12 +2173,17 @@ async def audit_tool_call( ValueError: If tool_name is empty. AxonFlowError: If audit recording fails. + Note: + `tool_type` is deprecated in favor of `caller_name` and is kept + only for backward compatibility. New callers should set + `caller_name` to identify which client made the call. + Example: >>> from axonflow.types import AuditToolCallRequest >>> result = await client.audit_tool_call( ... AuditToolCallRequest( ... tool_name="getUserInfo", - ... tool_type="mcp", + ... caller_name="claude_code", ... workflow_id="wf_abc123", ... success=True, ... duration_ms=45, @@ -2196,6 +2201,7 @@ async def audit_tool_call( self._logger.debug( "Audit tool call request", tool_name=request.tool_name, + caller_name=request.caller_name, tool_type=request.tool_type, ) diff --git a/axonflow/types.py b/axonflow/types.py index 8e1d249..dd59624 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -1482,8 +1482,18 @@ class AuditToolCallRequest(BaseModel): model_config = ConfigDict(populate_by_name=True) tool_name: str = Field(description="Name of the tool that was called") + caller_name: str | None = Field( + default=None, + description=( + "Identifies which client made the call (e.g., claude_code, codex, cursor, openclaw)" + ), + ) tool_type: str | None = Field( - default=None, description="Type of tool (e.g., mcp, api, function)" + default=None, + description=( + "Deprecated: use caller_name instead. Type of tool (e.g., mcp, api, function), " + "historically used to identify the calling client." + ), ) input: dict[str, Any] | None = Field(default=None, alias="input", description="Tool input data") output: dict[str, Any] | None = Field( diff --git a/runtime-e2e/caller_name_audit/README.md b/runtime-e2e/caller_name_audit/README.md new file mode 100644 index 0000000..493d716 --- /dev/null +++ b/runtime-e2e/caller_name_audit/README.md @@ -0,0 +1,63 @@ +# caller_name_audit (#2912) + +Real-stack proof for `AuditToolCallRequest.caller_name` +(getaxonflow/axonflow-enterprise#2912, sub-issue of epic #2905). + +`tool_type` on `audit_tool_call` was misleadingly named — every real caller +(claude_code/codex/cursor/openclaw) used it to identify *which client* made +the call, not any property of the tool. `caller_name` is the correctly-named +replacement; `tool_type` is kept as a deprecated input fallback (not removed). + +## What this proves + +Drives the real SDK's `client.audit_tool_call(...)` against a real running +agent+orchestrator and confirms the value actually lands in +`policy_details.caller_name` on the persisted `audit_logs` row — not just +that it marshals onto the wire correctly (that's covered by the +`httpx_mock`-based unit tests in `tests/test_audit_tool_call.py`): + +1. `caller_name="e2e-caller-name-probe-"` → `policy_details.caller_name` + equals that value verbatim. +2. Legacy `tool_type="mcp"` with **no** `caller_name` → the deprecated + fallback still resolves `policy_details.caller_name` to `"mcp"`. + +The SDK's typed `AuditLogEntry` does not declare a `policy_details` field +(it's an internal JSONB blob), so the read side uses a raw `httpx` call +against `GET /api/v1/audit/tenant/{tenant_id}` (the same route +`client.get_audit_logs_by_tenant` hits) to observe it — the only way to see +it from outside the platform. + +## Enterprise-mode identity scoping + +The tenant-audit read endpoint scopes non-tenant-wide callers to their own +`user_email` (#2922). This test patches the SDK's real httpx transport (not +a mock of the SDK — the same monkeypatch-the-transport technique +`runtime-e2e/x-client-id/test.py` uses) so every request, read and write, +carries a distinctive trust-gated `X-User-Email`. That requires +`AXONFLOW_TRUST_IDENTITY_HEADERS=true` on the platform (already set for the +local-dev Enterprise stack in `axonflow-enterprise/main-tree/.env`). In +Community mode, tenant-audit reads are tenant-wide regardless, so the header +is a no-op there. + +## Run + +``` +export AXONFLOW_AGENT_URL=http://localhost:8080 +export AXONFLOW_TENANT_ID=local-dev-org +export AXONFLOW_TENANT_SECRET= +python runtime-e2e/caller_name_audit/test.py +``` + +The orchestrator's `AuditLogger` batches writes (flush every 10s per +`platform/orchestrator/audit_logger.go`), so the test polls +`GET /api/v1/audit/tenant/{tenant_id}` for up to 45s before failing. + +Exits non-zero if `caller_name` (or the `tool_type` fallback) does not reach +`policy_details.caller_name` on the real row. + +## Companion unit coverage + +`tests/test_audit_tool_call.py` exercises the same surface through +`httpx_mock` for the wire-body shape (`caller_name` alone, `tool_type` alone, +both together, both omitted). This runtime proof is the real-stack +confirmation the `runtime-e2e/` DoD gate requires. diff --git a/runtime-e2e/caller_name_audit/test.py b/runtime-e2e/caller_name_audit/test.py new file mode 100644 index 0000000..cce33ae --- /dev/null +++ b/runtime-e2e/caller_name_audit/test.py @@ -0,0 +1,202 @@ +"""Real-stack assertion: AuditToolCallRequest.caller_name reaches +policy_details.caller_name on the audit_logs row (#2912, sub-issue of +epic #2905). + +getaxonflow/axonflow-enterprise PR #2953 added a `caller_name` field to +the orchestrator's tool-call audit path alongside the legacy `tool_type` +field (kept as a deprecated input fallback). The server resolves caller +identity as: caller_name if supplied -> legacy tool_type if supplied -> +a default. This test drives the real SDK's `client.audit_tool_call` +against a real running agent+orchestrator and proves the new field +actually lands in `policy_details.caller_name` on the persisted +audit_logs row -- not just that it marshals correctly onto the wire +(that's covered by the httpx_mock-based unit tests in +tests/test_audit_tool_call.py). + +Sister proof to the Go SDK's runtime-e2e/caller_name_audit/main.go. + +Steps: + 1. Call the real SDK's `audit_tool_call` with `caller_name` set to a + distinctive, non-default value and a unique `workflow_id` so we can + find the resulting row. + 2. Call it again with only the legacy `tool_type` set (no `caller_name`) + to prove the backward-compat fallback still resolves into + `policy_details.caller_name` on a real row. + 3. The orchestrator's AuditLogger batches writes (flush every 10s), so + poll `GET /api/v1/audit/tenant/{tenant_id}` (the same route + `client.get_audit_logs_by_tenant` hits) until each row lands. + 4. The SDK's typed `AuditLogEntry` does not surface `policy_details` + (it's an internal JSONB blob), so this step reads the raw JSON body + directly -- the only way to observe `policy_details.caller_name` + from outside the platform -- and asserts it equals what we sent. + +Enterprise-mode note: the tenant-audit read endpoint scopes non-tenant-wide +callers to their own `user_email` (#2922). This test trust-gates a +distinctive `X-User-Email` on every SDK request via a small httpx patch (the +same monkeypatch-the-real-transport technique used by +`runtime-e2e/x-client-id/test.py`) so the write and the read agree on +identity. This requires `AXONFLOW_TRUST_IDENTITY_HEADERS=true` on the +platform (already the case for the local-dev Enterprise stack); in +Community mode reads are tenant-wide regardless, so this is a no-op there. + +Usage:: + + export AXONFLOW_AGENT_URL=http://localhost:8080 + export AXONFLOW_TENANT_ID=local-dev-org + export AXONFLOW_TENANT_SECRET= + python runtime-e2e/caller_name_audit/test.py + +See ../README.md for how to register/obtain a tenant against a local stack. +""" + +from __future__ import annotations + +import asyncio +import base64 +import os +import sys +import time +import uuid + +import httpx + +from axonflow import AxonFlow +from axonflow.types import AuditToolCallRequest + +AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080") +CLIENT_ID = os.environ.get("AXONFLOW_TENANT_ID", "buku-e-py-e2e") +SECRET = os.environ.get("AXONFLOW_TENANT_SECRET", "buku-e-secret") + +# Distinctive per-run identity so the write and the read agree on scope +# under Enterprise-mode's role-scoped audit reads (#2922), and so repeated +# runs never collide on a stale row. +_RUN_ID = uuid.uuid4().hex[:12] +SCOPE_EMAIL = f"e2e-caller-name-{_RUN_ID}@runtime-e2e.axonflow.test" + +WANT_CALLER_NAME = f"e2e-caller-name-probe-{_RUN_ID}" +WORKFLOW_CALLER_NAME = f"e2e-caller-name-wf-{_RUN_ID}" +WORKFLOW_TOOL_TYPE_FALLBACK = f"e2e-tool-type-fallback-wf-{_RUN_ID}" +WANT_TOOL_TYPE_FALLBACK = "mcp" + +# Batch writer flushes every 10s (platform/orchestrator/audit_logger.go); +# give it generous headroom under CI/local load. +POLL_DEADLINE_SECONDS = 45.0 +POLL_INTERVAL_SECONDS = 2.0 + +# Patch the real httpx transport (not a mock -- we still send real +# requests over the real wire) so every SDK request carries a trust-gated +# X-User-Email identifying this test run. +_orig_request = httpx.AsyncClient.request + + +async def _patched_request(self, method, url, **kw): + headers = dict(kw.get("headers") or {}) + headers["X-User-Email"] = SCOPE_EMAIL + kw["headers"] = headers + return await _orig_request(self, method, url, **kw) + + +httpx.AsyncClient.request = _patched_request + + +def _fail(msg: str) -> None: + sys.stderr.write(f"FAIL: {msg}\n") + sys.exit(1) + + +def _fetch_policy_details(workflow_id: str) -> dict[str, object]: + """Poll GET /api/v1/audit/tenant/{tenant_id} for the row with this + workflow_id (audit_logs.request_id) and return its raw policy_details. + + Uses raw httpx (not the SDK) because AuditLogEntry does not declare a + policy_details field -- it's the only way to observe it from outside + the platform. + """ + auth = base64.b64encode(f"{CLIENT_ID}:{SECRET}".encode()).decode() + headers = { + "Authorization": f"Basic {auth}", + "X-User-Email": SCOPE_EMAIL, + } + deadline = time.monotonic() + POLL_DEADLINE_SECONDS + last_seen_total = None + while True: + resp = httpx.get( + f"{AGENT_URL}/api/v1/audit/tenant/{CLIENT_ID}?limit=50", + headers=headers, + timeout=15.0, + ) + if resp.status_code != 200: # noqa: PLR2004 + _fail(f"GET /api/v1/audit/tenant/{CLIENT_ID} HTTP {resp.status_code}: {resp.text}") + body = resp.json() + last_seen_total = body.get("total") + for entry in body.get("entries", []): + if entry.get("request_id") == workflow_id: + return entry.get("policy_details") or {} + if time.monotonic() > deadline: + _fail( + f"audit row for workflow_id={workflow_id!r} did not appear within " + f"{POLL_DEADLINE_SECONDS}s (batch flush may not have fired; " + f"last seen total={last_seen_total})" + ) + time.sleep(POLL_INTERVAL_SECONDS) + + +async def main() -> None: + async with AxonFlow(endpoint=AGENT_URL, client_id=CLIENT_ID, client_secret=SECRET) as client: + # 1. caller_name reaches policy_details.caller_name verbatim. + resp1 = await client.audit_tool_call( + AuditToolCallRequest( + tool_name="e2eCallerNameTool", + caller_name=WANT_CALLER_NAME, + workflow_id=WORKFLOW_CALLER_NAME, + ) + ) + print( + f"SDK audit_tool_call (caller_name) -> audit_id={resp1.audit_id} status={resp1.status}" + ) + + # 2. Legacy tool_type-only (no caller_name) still resolves into + # policy_details.caller_name -- the deprecated backward-compat path. + resp2 = await client.audit_tool_call( + AuditToolCallRequest( + tool_name="e2eToolTypeFallbackTool", + tool_type=WANT_TOOL_TYPE_FALLBACK, + workflow_id=WORKFLOW_TOOL_TYPE_FALLBACK, + ) + ) + print( + f"SDK audit_tool_call (tool_type fallback) -> audit_id={resp2.audit_id} status={resp2.status}" + ) + + policy_details_1 = _fetch_policy_details(WORKFLOW_CALLER_NAME) + got_caller_name = policy_details_1.get("caller_name") + if got_caller_name != WANT_CALLER_NAME: + _fail( + f"policy_details.caller_name = {got_caller_name!r}, want {WANT_CALLER_NAME!r} " + f"(full policy_details: {policy_details_1})" + ) + print( + f"PASS: policy_details.caller_name = {got_caller_name!r} reached the audit_logs " + f"row via the real agent+orchestrator stack" + ) + print(f"Wire policy_details (caller_name case): {policy_details_1}") + + policy_details_2 = _fetch_policy_details(WORKFLOW_TOOL_TYPE_FALLBACK) + got_fallback = policy_details_2.get("caller_name") + if got_fallback != WANT_TOOL_TYPE_FALLBACK: + _fail( + f"backward-compat: policy_details.caller_name = {got_fallback!r} " + f"(from legacy tool_type={WANT_TOOL_TYPE_FALLBACK!r}), want {WANT_TOOL_TYPE_FALLBACK!r} " + f"(full policy_details: {policy_details_2})" + ) + print( + f"PASS: legacy tool_type={WANT_TOOL_TYPE_FALLBACK!r} still resolves into " + f"policy_details.caller_name = {got_fallback!r} (deprecated fallback intact)" + ) + print(f"Wire policy_details (tool_type fallback case): {policy_details_2}") + + print("ALL PASS: caller_name (#2912) verified end-to-end through the real SDK + platform") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 7d592a2..1f1dab4 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -200,6 +200,13 @@ ], "spec_only": [] }, + "AuditToolCallRequest": { + "note": "acknowledged-sdk-superset: tracked in getaxonflow/axonflow-enterprise#2912. SDK declares caller_name; platform accepts/writes it (PR #2953) but the community OpenAPI spec has not caught up yet.", + "sdk_only": [ + "caller_name" + ], + "spec_only": [] + }, "Budget": { "note": "acknowledged-sdk-superset: tracked in #1745. SDK declares `enabled`; platform emits it but spec doesn't yet declare it.", "sdk_only": [ diff --git a/tests/test_audit_tool_call.py b/tests/test_audit_tool_call.py index c5afb43..76def72 100644 --- a/tests/test_audit_tool_call.py +++ b/tests/test_audit_tool_call.py @@ -159,6 +159,109 @@ async def test_401_not_retried_issue_2275( # and fail. assert len(httpx_mock.get_requests()) == 1 + @pytest.mark.asyncio + async def test_caller_name_sent_in_request_body( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test that caller_name is sent in the outgoing request body when supplied.""" + httpx_mock.add_response( + status_code=201, + json={ + "audit_id": "aud_caller", + "status": "recorded", + "timestamp": "2026-03-14T13:00:00Z", + }, + ) + + request = AuditToolCallRequest( + tool_name="getUserInfo", + caller_name="claude_code", + success=True, + ) + + result = await client.audit_tool_call(request) + + assert result.audit_id == "aud_caller" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + import json + + body = json.loads(sent_request.content) + assert body["caller_name"] == "claude_code" + assert "tool_type" not in body + + @pytest.mark.asyncio + async def test_tool_type_standalone_backward_compat( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test that tool_type alone still works without caller_name (backward compat).""" + httpx_mock.add_response( + status_code=201, + json={ + "audit_id": "aud_legacy", + "status": "recorded", + "timestamp": "2026-03-14T13:15:00Z", + }, + ) + + request = AuditToolCallRequest( + tool_name="getUserInfo", + tool_type="mcp", + success=True, + ) + + result = await client.audit_tool_call(request) + + assert result.audit_id == "aud_legacy" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + import json + + body = json.loads(sent_request.content) + assert body["tool_type"] == "mcp" + assert "caller_name" not in body + + @pytest.mark.asyncio + async def test_caller_name_and_tool_type_together( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + """Test that caller_name and tool_type can both be supplied together.""" + httpx_mock.add_response( + status_code=201, + json={ + "audit_id": "aud_both", + "status": "recorded", + "timestamp": "2026-03-14T13:30:00Z", + }, + ) + + request = AuditToolCallRequest( + tool_name="getUserInfo", + caller_name="codex", + tool_type="mcp", + success=True, + ) + + result = await client.audit_tool_call(request) + + assert result.audit_id == "aud_both" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + import json + + body = json.loads(sent_request.content) + assert body["caller_name"] == "codex" + assert body["tool_type"] == "mcp" + @pytest.mark.asyncio async def test_excludes_none_fields_from_request( self, @@ -240,6 +343,21 @@ def test_response_model_validate(self) -> None: assert response.status == "recorded" assert response.timestamp == "2026-03-14T10:00:00Z" + def test_request_model_validate_with_caller_name(self) -> None: + """Test AuditToolCallRequest model validation with caller_name.""" + request = AuditToolCallRequest.model_validate( + { + "tool_name": "myTool", + "caller_name": "cursor", + "input": {"key": "value"}, + "duration_ms": 100, + } + ) + + assert request.tool_name == "myTool" + assert request.caller_name == "cursor" + assert request.tool_type is None + def test_request_serialization_excludes_none(self) -> None: """Test that model_dump excludes None fields.""" request = AuditToolCallRequest(tool_name="myTool") From ffb22d8b3c8c67b04ee2d738ee6b13310e6dc156 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 17 Jul 2026 22:35:13 +0200 Subject: [PATCH 2/2] docs(audit): note platform floor for caller_name attribution A pre-#2953 platform silently drops caller_name, so a caller who sets only caller_name on an older platform gets the default with no in-IDE attribution hint. Add the platform-floor caveat (requires v9.11.0+; set tool_type as a fallback on older platforms) to the caller_name field doc comment. Also add the "platform support not yet on main" prerequisite section to the runtime-e2e README, matching the TS/Java SDKs. getaxonflow/axonflow-enterprise#2912 Signed-off-by: Saurabh Jain --- axonflow/types.py | 4 +++- runtime-e2e/caller_name_audit/README.md | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/axonflow/types.py b/axonflow/types.py index dd59624..dce7b84 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -1485,7 +1485,9 @@ class AuditToolCallRequest(BaseModel): caller_name: str | None = Field( default=None, description=( - "Identifies which client made the call (e.g., claude_code, codex, cursor, openclaw)" + "Identifies which client made the call (e.g., claude_code, codex, cursor, openclaw). " + "Requires a platform with caller_name support (v9.11.0+); older platforms silently " + "drop this field, so also set tool_type if you need attribution there." ), ) tool_type: str | None = Field( diff --git a/runtime-e2e/caller_name_audit/README.md b/runtime-e2e/caller_name_audit/README.md index 493d716..5de1492 100644 --- a/runtime-e2e/caller_name_audit/README.md +++ b/runtime-e2e/caller_name_audit/README.md @@ -39,6 +39,19 @@ local-dev Enterprise stack in `axonflow-enterprise/main-tree/.env`). In Community mode, tenant-audit reads are tenant-wide regardless, so the header is a no-op there. +## Prerequisite: platform support is not yet on `main` + +`caller_name` support (axonflow-enterprise#2953) is implemented but, as of +this writing, still an open PR on the `feat/2912-caller-name-tool-type-deprecation` +branch — not yet merged to `axonflow-enterprise` main. Against a stack built +from `axonflow-enterprise` main, this test will FAIL (the 45s poll of +`GET /api/v1/audit/tenant/{tenant_id}` times out waiting for +`policy_details.caller_name`, which the server doesn't write yet) — that's +not a bug in this test, it means the platform side isn't deployed on +whatever stack you're pointed at. Point your local `axonflow-enterprise` +checkout at that branch (or a later commit that includes it) before running +this test. + ## Run ```