Event-driven plumbing that wakes a Claude Code agent from a Kafka message and streams every step of its run back out as CloudEvents.
Four phases, built in order, each a delta over the last: a local wire contract
(Phase 0), KEDA scale-to-zero on Kubernetes (Phase 1), identity and signing on the
event path (Phase 2), and per-user isolation with declarative agents (Phase 3,
partly implemented). Phase 4 — enrollment and human-in-the-loop approval — is
designed but not built. docs/specs/README.md is the
registry, and it is blunt about which is which.
The sections below describe the system as it runs today. Phase 0 remains the one to read first, because it pins down the wire contract — the CloudEvent envelope, correlation identity and per-conversation ordering — that every later phase assumes and none of them changes.
Extracted from
rossoctl/examples(eventing/), with git history preserved.
HTTP → EventBridge → Kafka:requests → EventRunner → claude -p
│
▼
HTML view / SSE ← EventBridge ← Kafka:responses ← stream-json → CloudEvents
A request enters over HTTP, becomes a CloudEvent on the requests topic, gets
picked up by EventRunner which spawns claude -p --output-format stream-json,
and each stream-json frame is republished to responses. EventBridge consumes
those back and serves them as JSON, ndjson, SSE, or a live HTML transcript.
| Path | Role |
|---|---|
eventbridge/ |
WSGI HTTP front door (:8080), Kafka producer + consumer, SQLite event store, HTML/SSE views, ntfy fan-out, startup network self-test |
eventrunner/ |
Kafka consumer that spawns claude per request and converts stream-json into response CloudEvents |
shared/ |
CloudEvent envelope helpers, binary-mode Kafka binding, PID files, correlation word lists |
tests/ |
978 unit tests across 43 files — no Kafka or claude needed |
scripts/stop-demo.sh |
SIGINT → SIGTERM → SIGKILL both daemons via their PID files |
Dockerfile-eventbridge, Dockerfile-eventrunner |
One image per service, free-threaded CPython supplied by uv |
scripts/build-images.sh |
Multi-arch build (linux/amd64,linux/arm64) + push, or --local single-arch |
scripts/push-images.sh |
Pushes locally-tagged images (companion to --local) |
scripts/docker-e2e-test.sh |
Builds both images, stands up Kafka, drives one turn over a fresh host port |
Two console entry points, defined in pyproject.toml:
uv run rossoctl-eventbridge # HTTP + Kafka bridge
uv run rossoctl-eventrunner # claude executorCorrelation identity is human-readable, session identity is not.
A run gets a slug like brave-otter-4718 for humans, and claude gets
uuid5(NAMESPACE, correlationid) because it requires a real UUID. The slug
never reaches the CLI directly. Continuing a conversation reuses both, so
claude resumes the same session.
Turns are FIFO per conversation, parallel across conversations.
EventRunner's router gives each correlationid a slot and serializes its turns
in arrival order, while distinct correlationids run concurrently up to
ER_MAX_CONCURRENT (default 4). Three concurrent /continue calls on one
correlationid land as three contiguous, non-interleaved blocks. This is the
contract in docs/specs/0001-phase0-local-wire-contract.md §4.5, and
tests/test_router.py proves it —
FIFO order, cross-correlation parallelism, the concurrency cap, and slot
re-arm after drain.
Requires Python 3.14 free-threaded (python3.14t), uv, a local Kafka 4.x
(KRaft) on :9092 with topics requests and responses, and the claude CLI.
uv run rossoctl-eventbridge # terminal 1
uv run rossoctl-eventrunner # terminal 2Mock mode is the default when no credential is in the environment. With
neither ANTHROPIC_AUTH_TOKEN nor ANTHROPIC_API_KEY set, EventRunner returns
a deterministic system → assistant → result sequence with the same wire shape
as real Claude — no subprocess, no API tokens. That makes a bare run useful
instead of failing on auth for every request, and it tells wire problems apart
from claude problems. The startup line always states the mode and why:
[eventrunner] bootstrap=… mock_claude=True (auto: none of ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY set)
An explicit ER_MOCK_CLAUDE always wins over the auto-detection. You need
ER_MOCK_CLAUDE=false in one common case: claude authenticated through
claude login rather than an API key, where there is no env var to detect.
Drive it:
CORR=$(curl -sS -X POST http://127.0.0.1:8080/v0/agents \
-H 'Content-Type: application/json' \
-d '{"prompt":"Name one animal starting with O.","max_turns":1}' \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["correlationid"])')
open "http://127.0.0.1:8080/v0/agents/$CORR" # live HTML transcriptStop everything with scripts/stop-demo.sh.
| Endpoint | Returns |
|---|---|
POST /v0/agents |
Start a run; mints the correlationid |
POST /v0/agents/{corr}/continue |
Another turn on the same claude session |
GET /v0/agents/{corr} |
Live HTML transcript, turn-grouped, SSE-backed |
GET /v0/agents/{corr}/events |
JSON events, ?since=<seq> for incremental polling |
GET /v0/agents/{corr}/events.jsonl |
Raw CloudEvents as ndjson — the Phase 1 downstream shape |
GET /v0/agents/{corr}/events.sse |
True server push |
GET /v0/agents/{corr}/turns |
Prompt paired with reply per turn, plus cost/duration stats |
POST /v0/groups |
Create a batch and fan out its members; Idempotency-Key honoured |
GET /v0/groups |
Recent groups — HTML by default, JSON with Accept: application/json |
GET /v0/groups/{gid} |
Live batch progress page: percent, ETA, per-member rows |
GET /v0/groups/{gid}/status |
The JSON the CLI polls; ?full=1 embeds every member |
POST /v0/groups/{gid}/close |
Stop expecting more members (optionally revise expected) |
POST /v0/groups/{gid}/cancel |
Give up on the stragglers and complete now |
GET /v0/selftest |
Which public base URL a phone can actually reach |
GET /docs, GET /healthz |
Swagger UI, liveness |
Every response event carries data.text at the top level, so extracting a
reply never means walking into data.raw.message.content[0].text.
POST /v0/groups takes prompts — a flat list of strings, not member objects:
curl -X POST http://127.0.0.1:8080/v0/groups -H 'Content-Type: application/json' \
-d '{"label":"hello-3","prompts":["Say Hello 1","Say Hello 2","Say Hello 3"]}'Optional alongside it: expected (for an open group fed later — required when prompts
is omitted, and rejected when it contradicts len(prompts)), label, min_success,
deadline_s, max_turns (default 3), model, and an Idempotency-Key header so a
retried POST returns the same group rather than launching a second batch. Passing
members[] instead fails with {"error": "give either prompts[] or expected"}.
A batch sends exactly two ntfy notifications — one when it starts, one when it
finishes — no matter how many agents it contains; while a group is open its members'
own notifications are suppressed. Group state is a cache: /data is emptyDir in the
test and kind overlays, so on every start EventBridge replays the responses topic
to rebuild it ([group-mirror] rebuilt N group(s) in the log), which is what keeps a
notification's link working after a pod restart.
| Env | Default | Effect |
|---|---|---|
ER_MOCK_CLAUDE |
auto | Deterministic fake responses, zero API cost. Unset means "mock unless a credential is present"; an explicit true/false overrides that |
ER_MAX_CONCURRENT |
4 |
Distinct correlationids running in parallel |
ER_INCLUDE_RAW |
true |
Keep the full stream-json frame under data.raw |
ER_DEDUPE_FINAL_TEXT |
true |
Strip the duplicate final text that stream-json always emits twice |
KAFKA_BOOTSTRAP |
localhost:9092 |
Broker address |
CLAUDE_BIN |
claude |
CLI path if not on PATH |
NTFY_TOPIC / NTFY_ENABLED |
off | Push result/error phases to a phone |
EVENT_BRIDGE_PUBLIC_BASE_URL |
— | Used verbatim in HTML and ntfy links |
Added in Phase 1:
| Env | Default | Effect |
|---|---|---|
ER_CONSUMER_GROUP |
eventrunner |
Kafka consumer group. Must equal the KEDA trigger's consumerGroup — a mismatch is a silent no-scale failure |
ER_COMMIT_AFTER_TERMINAL |
true |
Commit offsets only after the terminal event, so lag means "work not finished" and KEDA cannot scale a streaming pod away |
ER_MAX_REQUEST_AGE_S |
3600 |
Drop replayed requests older than this instead of re-running them. 0 disables |
ER_DRAIN_TIMEOUT_S |
570 |
How long SIGTERM waits for in-flight runs. Keep below terminationGracePeriodSeconds |
ER_HEARTBEAT_PATH / ER_HEARTBEAT_MAX_AGE_S |
$TMPDIR/…/heartbeat, 90 |
Liveness heartbeat; python -m eventrunner.healthcheck is the probe |
ER_EVENTBRIDGE_URL |
— | Where to checkpoint transcripts and read the last sequence. Unset disables both |
ER_TRANSCRIPT_MAX_BYTES / EB_TRANSCRIPT_MAX_BYTES |
32 MiB | Transcript size cap. A trivial one-turn transcript is already ~222 KB |
ER_KAFKA_RETRY_INITIAL_S / ER_KAFKA_RETRY_MAX_S |
1 / 30 |
Backoff bounds for the consumer's connect retry |
ER_REQUIRE_SIGNATURE |
false |
Refuse unsigned/badly-signed requests |
ER_SIGNING_KEY_PATH / ER_VERIFY_KEY_PATH |
— | Ed25519 seed (hex, base64 or 32 raw bytes) |
EB_AUTH_TOKENS |
— (open) | name:token pairs guarding the two routes that create work. Empty means no authentication, and no manifest sets it — see below |
EB_AUTH_TOKENS deserves its own note because the default is fail-open. With it
unset — which is how every manifest in k8s/ ships — an unauthenticated
POST /v0/agents returns 202 and spawns a claude run, and the OpenShift Route
makes that reachable from outside the cluster. Auth on the HTTP surface is
deferred to Phase 2 on purpose; until then, set this whenever the Route is
reachable by anyone you would not hand a shell to. It guards only
POST /v0/agents and POST /v0/groups; GETs, /healthz, /docs,
PUT /transcript and /continue stay open by design. Read only from the
environment, never from config.toml, which is committed. Deployment recipe and
the full list of what stays open: docs/demos/phase1-demo.md §
"Submit-path authentication".
Two of these are load-bearing in ways that are easy to miss.
ER_EVENTBRIDGE_URL is what makes /continue survive a scale-to-zero and what
keeps event sequence numbers unique across pods — without it a second pod
restarts numbering at 1 and its rows overwrite the first turn's.
ER_CONSUMER_GROUP must match the ScaledObject exactly or KEDA measures a group
nobody joins.
Added in Phase 3 (docs/specs/0004-phase3-tenancy-agents-triggers.md). Only the variables whose code is
actually in the tree — §9's steps 1–4. Every one of them is off or single-tenant by
default, so an existing deployment that upgrades and changes nothing behaves exactly
as it did:
| Env | Default | Effect |
|---|---|---|
EB_TENANCY_MODE |
single |
single reproduces Phase 2: one topic pair, one store, no ownership checks. multi switches on per-user topics and requires an authenticated, registered caller |
EB_TOPIC_PREFIX |
kev1 |
First component of every per-user topic name, {prefix}-{userkey}-requests. Unused in single mode |
EB_USER_REGISTRY_PATH |
— | The approved-user registry (a ConfigMap, §2.5). Required in multi mode, and an empty one denies everyone — the other reading turns a missing file into an open door |
EB_MAX_OPEN_STORES |
64 |
LRU ceiling on open per-user SQLite stores. Two WAL connections per tenant (main + -wal + -shm), so a 1024 soft descriptor limit bounds this near 150–200 tenants |
ER_USERKEY |
— | Which tenant this runner serves. Stamped on every response so EventBridge can file it. Required once REQUEST_TOPIC is not requests; the runner refuses to start otherwise |
ER_AGENT_DIR |
/etc/rossoctl/agents |
Where baked AgentSpecs live (§5.2) |
ER_AGENT_NAME |
default |
Fallback agent when neither the request nor the registry names one. default with no file on disk is Phase 2's argv exactly |
Three notes on these. multi mode is not yet isolation you should present as
such.
Per-user stores are implemented (T5), so each tenant's sessions, responses and
transcripts live in their own SQLite files and a read routes to the owning tenant's store
— but nothing yet checks that the caller is that owner, because owner-scoped reads (T7)
and transcript authentication (T8) are not in. So in multi mode the HTTP reads,
/continue and PUT /transcript are still as open as Phase 2's. And even once they
land, separate topics without Kafka ACLs are organisation, not isolation;
docs/specs/0004-phase3-tenancy-agents-triggers.md §3.6 and §10 are explicit about which claim each configuration earns.
multi also does not work end to end yet. Until T6 neither consumer subscribes to
the per-user topics, so no response is ever consumed: transcript pages stay empty, group
counters never advance, and nothing is logged. Treat it as something to develop against,
not to run.
Adding a user is an operator action. EventBridge has no Kubernetes client and will not create topics, so a registry entry whose topics do not exist is a refusal, not an auto-provision.
EventRunner does not hand its whole environment to the child process. It
forwards a curated set — ANTHROPIC_BASE_URL, ANTHROPIC_MODEL,
ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY,
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS, CLAUDE_CONFIG_DIR,
CLAUDE_CODE_PROJECT_DIR_NAME, plus the basics claude needs to find its session
store — and logs which routing vars it will pass, showing secrets as head + length
only. That makes routing through LiteLLM, cortex, or
an on-prem gateway a matter of exporting two variables.
One image per service, built from this directory. scripts/build-images.sh
wraps both dockerfiles and produces a multi-arch manifest by default:
./scripts/build-images.sh # linux/amd64 + linux/arm64, pushed to quay.io/aslomnet/
./scripts/build-images.sh --local # single-arch host build, loaded locally, not pushed
./scripts/build-images.sh --tag v0.1.0
REGISTRY_PREFIX=ghcr.io/aslom/ ./scripts/build-images.shDefault output on the registry:
quay.io/aslomnet/rossoctl-eventbridge:dev— OCI image index overlinux/amd64andlinux/arm64, with SLSA provenance and SBOM attestationsquay.io/aslomnet/rossoctl-eventrunner:dev— same shape
Multi-arch requires docker buildx and an amd64 emulator. On macOS with
Apple Silicon, use Rosetta rather than QEMU — the JIT-to-arm64 path is
roughly an order of magnitude faster for CPU-bound steps like the CPython
install. Enable it once in Rancher Desktop under Preferences → Virtual
Machine → Emulation (VZ type + "Rosetta support") or in Docker Desktop
under Settings → General. The script probes on startup and warns if the
active emulator looks like slow QEMU rather than Rosetta.
The old, plain single-shot flow still works:
docker build -f Dockerfile-eventbridge -t rossoctl/eventbridge:dev .
docker build -f Dockerfile-eventrunner -t rossoctl/eventrunner:dev .Both install a uv-managed free-threaded CPython — there is no python:3.14t
tag on Docker Hub to base on — and assert the GIL is off at build time. They
install only [project.dependencies] via uv pip install -r pyproject.toml
rather than the project itself: the source is copied in and run from /app, so a
built wheel buys nothing and skipping the build keeps the layer cache useful.
Image-specific defaults that differ from a local run:
| Setting | Why |
|---|---|
EB_HTTP_ADDR=0.0.0.0:8080 |
The 127.0.0.1 default is unreachable from outside the container |
KAFKA_BOOTSTRAP=kafka:9092 |
localhost means the container itself; kafka is the expected network alias |
TMPDIR=/data (a volume) |
Where the SQLite store, pidfiles and per-run work dirs land |
The runner image deliberately does not vendor the claude CLI — it is
distributed per platform and versioned separately. Mock mode means the image is
still useful on its own; for real runs, install the CLI in a derived image or
bind-mount one and set CLAUDE_BIN.
The end-to-end script builds both images, starts a single-node KRaft Kafka, creates the topics, boots both services, and drives one full turn over a new host port (18080, so it will not collide with a local demo on 8080):
./scripts/docker-e2e-test.sh # build, assert, tear down
./scripts/docker-e2e-test.sh --port 19090
./scripts/docker-e2e-test.sh --keep # leave it running to poke atIt asserts the mock-mode decision three ways directly against the image (no
credential → mock, credential → real, explicit override → real), then that
/healthz answers on the new port, that the startup log explains the chosen
mode, that a MOCK-REPLY event completes the round trip, and that
events.jsonl and /turns render it. Any credential in your shell is not
forwarded unless you pass --real, so the run stays free and deterministic.
Logs for every step land in $TMPDIR/rossoctl-eventing-e2e/.
uv run --extra dev python -m pytest tests -q # 978 passed, 7 skipped, 1 xfailedNo Kafka, no broker, no claude. Coverage spans the router contract,
CloudEvent round-trips in binary mode, the SQLite store, correlation IDs, turn
grouping, graceful shutdown, PID file reclaim, OpenAPI generation, ntfy body
construction, and the network self-test.
If the host is on a full-tunnel corporate VPN (Cisco AnyConnect TUNNELALL), a
phone on the same wifi cannot reach the host's LAN address — return traffic
routes out the tunnel. EventBridge detects this at startup and prints a verdict
per candidate address (recommended, reachable-lan-only,
reachable-vpn-only, reachable-same-host-only, not-listening). The fix is
a tunnel or mesh: cloudflared for a one-off, ngrok for recurring dev,
Tailscale for multiple devices long-term.
Phase 1 is implemented. The wire contract is unchanged; what changed is where the consumer runs and who decides how many of them there are.
HTTP ─▶ EventBridge ─▶ Kafka:requests ─▶ EventRunner ─▶ claude -p
(Deployment, (Deployment,
replicas=1, replicas 0..6)
public URL) ▲
│ scales on consumer lag
KEDA ScaledObject
An idle demo consumes no agent capacity; a posted request wakes a pod in a few seconds. EventBridge stays at one replica — something has to accept the POST and hold the event store, so the claim is "no agent capacity while idle", not "nothing is running".
What arrived with it:
- KEDA
ScaledObjecton consumer-group lag,minReplicaCount: 0, capped by the requests topic's partition count. - Offsets commit after the terminal event, so lag means "work not finished" and KEDA cannot scale a streaming pod away mid-run.
- A blocking Kafka retry loop and a heartbeat liveness probe. The old failure was a live process with a dead consumer thread: the pod reported healthy and consumed nothing.
- A graceful drain. SIGTERM waits for in-flight
clauderuns and says on stderr what it is waiting for. - Transcript checkpointing through EventBridge, so
/continueresumes a session even after the pod that started it was scaled away. It rests on one verified fact:claude --resumeaccepts a path to a transcript.jsonl, not only a session id — seescripts/verify_resume_by_path.py. - Sequence continuity across pods. A new pod would otherwise restart event
sequenceat 1 and overwrite the previous turn's stored rows. ce_causationidbinding every response event to its triggering request, and JWS signing over the envelope (pure-Python Ed25519, feature-flagged off).- Python tooling. Every script in
scripts/is Python and stdlib-only; the historically documented entry points keep one-lineexecshims.
Manifests live in k8s/ (base/, topics/, kind/, overlays/{test,kind,demo});
the design and build records are in docs/.
Nothing secret is committed: the demo overlay references a Secret that
k8s_deploy.py creates from your environment at deploy time.
| What it proves | Needs | |
|---|---|---|
| Local | the logic: 978 unit tests | Python 3.14t, uv |
| Kind | the whole KEDA story, no shared cluster, no VPN | Docker + kind |
| OpenShift / ykt1 | the same, plus the arbitrary-UID and multi-node paths Kind cannot test | cluster access |
Docker-only (no Kubernetes) still works too — see Containers above for
./scripts/docker-e2e-test.sh.
Setup
uv venv --python 3.14t .venv
uv pip install --python .venv/bin/python kafka-python cloudevents pytest
.venv/bin/python -c 'import sys; print("GIL:", sys._is_gil_enabled())' # -> FalseTests
PYTHONPATH=$PWD .venv/bin/python -m pytest tests/ -q
# Force mock mode. Do this if you have a real credential exported: with
# ER_MOCK_CLAUDE unset, config.load() auto-selects REAL claude when it sees
# ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY.
ER_MOCK_CLAUDE=true PYTHONPATH=$PWD .venv/bin/python -m pytest tests/ -qExpect 378 passed, 7 skipped (the skips want a local Kafka) as of this branch —
measured on macOS 15 / arm64 with CPython 3.14.0rc1 free-threaded. The three
test_cli_output.py failures noted earlier no longer reproduce; they read a skill file
that lives outside this repository, so they depend on where the checkout sits.
The one paid test. The transcript-checkpoint design rests on claude --resume
accepting a path. This proves it for about two cents, and includes the negative
control that makes the result meaningful:
.venv/bin/python scripts/verify_resume_by_path.pyThe broker. The Quickstart assumes a Kafka 4.x (KRaft) already on :9092; there is
no script for it. Without containers, from the Apache tarball and a JDK 17+:
tar xzf kafka_2.13-4.1.0.tgz && cd kafka_2.13-4.1.0
sed -i '' "s#^log.dirs=.*#log.dirs=$PWD/data#" config/server.properties # /tmp may be unwritable
bin/kafka-storage.sh format -t "$(bin/kafka-storage.sh random-uuid)" \
-c config/server.properties --standalone
bin/kafka-server-start.sh config/server.properties &
for t in requests responses; do
bin/kafka-topics.sh --bootstrap-server 127.0.0.1:9092 \
--create --topic $t --partitions 12 --replication-factor 1
done12 partitions matches the Phase 1 overlays, so local and cluster runs stay comparable.
In kafka-consumer-groups.sh --describe, lag means unfinished work, not lateness —
the offset commit is deferred to the terminal event, which is what makes lag a usable
queue gauge during a batch.
The demo is unchanged from Phase 0 — see Quickstart.
Setup. A default kind create cluster is not sufficient: ingress-nginx
schedules with a nodeSelector on ingress-ready=true and binds host ports, so
without both in the cluster config nothing is reachable.
k8s/kind/kind-cluster.yaml supplies them.
python3 scripts/kind_setup.py --check # what is missing, changes nothing
python3 scripts/kind_setup.py --recreate # build the cluster from that config
python3 scripts/kind_setup.py # install into an existing oneThat installs exactly what a bare cluster lacks: ingress-nginx, Strimzi watching
namespace kafka plus a single-node Kafka named my-cluster (same name and
bootstrap DNS as ykt1, which is why base/configmap.yaml needs no change), and
KEDA.
Kind has no CPU/memory knobs. Each node is a container and the ceiling is the
Docker/Rancher/Colima VM; cpu:/memory: in a Kind config do nothing. A short VM
shows up as Pending pods or an OOMKilled broker, not as a Kind error.
| CPU | Memory | |
|---|---|---|
| Minimum | 4 | 8 GiB |
| Recommended | 6 | 12 GiB |
| Verified on | 8 | 24 GiB |
kind_setup.py reports what it found and fails below the minimum.
Deploy and test
KC=../../.kube/config-kind # every script takes --kubeconfig
python3 scripts/k8s_preflight.py --kubeconfig $KC --overlay kind --yes
python3 scripts/k8s_deploy.py --kubeconfig $KC --overlay kind --yes
python3 scripts/k8s_e2e_test.py --kubeconfig $KC --overlay kind
python3 scripts/k8s_teardown.py --kubeconfig $KCPublic URL: http://eventbridge.127.0.0.1.nip.io:30080 (nip.io resolves
*.127.0.0.1.nip.io to loopback, so no /etc/hosts editing).
On Apple Silicon the cluster is arm64: eventbridge/eventrunner are multi-arch and
just work, but the derived claude image is amd64-only, so for the demo overlay
rebuild it natively with
python3 scripts/build_images.py --with-claude --claude-platforms linux/arm64.
Setup. Nothing to install — Strimzi, KEDA and the Route API are already there. Just confirm you are pointed at the right cluster; preflight refuses to act on an unacknowledged current-context, because deploying into the wrong cluster is the worst outcome available here.
python3 scripts/k8s_preflight.py --yes # 13 checks, each with its remediation
python3 scripts/k8s_preflight.py --context <ctx> # or pin it explicitlyDeploy and test
python3 scripts/k8s_deploy.py --yes # test overlay into kev1
python3 scripts/k8s_deploy.py --yes --check-only # is a deploy needed?
python3 scripts/k8s_e2e_test.py # 12 assertions, free, mock mode
python3 scripts/k8s_teardown.py # back to zero cost, deletes nothingDeploy runs in a fixed order and gates on the public URL: the Route hostname is
published as EVENT_BRIDGE_PUBLIC_BASE_URL and then proven with a GET /healthz
from outside the cluster before anything else counts. The idle state it verifies is
zero EventRunner replicas — that is correct, not a failure.
The four-stage loop — zero → wake → stream → zero — asserted with timings rather
than narrated. Add --kubeconfig $KC --overlay kind for Kind.
# free and deterministic (mock mode), ~6 min
python3 scripts/k8s_demo_flow.py
# the same plus T1.10: 3 concurrent conversations across pods
python3 scripts/k8s_demo_flow.py --concurrency 3
# faster and weaker: skips the cold /continue and the replay guard
python3 scripts/k8s_demo_flow.py --skip-cold-continue --skip-idle-replay| Stage | Asserted |
|---|---|
| 0 | 0 replicas, no lingering pod, Active=False before anything is posted |
| 1 | Active=True and replicas ≥ 1, wake latency recorded |
| 2 | a phase=stdout event, then a terminal one; replicas stayed ≥ 1 for the whole run |
| 2b | /continue on a warm pod; the transcript is checkpointed |
| 2c | /continue after a full scale-to-zero cycle — the Gap B gate |
| 3 | replicas return to 0; Active=False |
| T1.10 | --concurrency N: more than one pod, each /continue resuming its own session |
| T5.6 | a group whose offsets were deleted does not replay the backlog |
Watch it happen in a second window:
kubectl -n kev1 get deploy eventrunner -wThe real-agent demo needs a credential, the derived claude image, and real
mode. Nothing secret is committed — the values come from your environment and are
applied via stdin (not --from-literal, which would put the token in ps output):
export ANTHROPIC_BASE_URL=https://ete-litellm.ai-models.vpc.res.ibm.com
export ANTHROPIC_AUTH_TOKEN=<your-litellm-virtual-key>
export ANTHROPIC_MODEL=claude-haiku-4-5-20251001 # optional
python3 scripts/build_images.py --with-claude
python3 scripts/k8s_deploy.py --yes --overlay demo
python3 scripts/k8s_demo_flow.py --overlay demo --concurrency 3Use the vpc host, not vpc-int: vpc-int resolves to IBM-internal 9.x
addresses that the cluster's AWS worker nodes cannot route — it works from a laptop
on the VPN and times out in a pod. The deploy script warns if you pass one.
Mock mode cannot demonstrate retained context (it emits a fixed reply), so stage 2c
and --concurrency only fully close their gates with a real agent.
The skill CLI works against any of the three environments. It defaults to
http://127.0.0.1:8080, so pass --base-url when you mean a cluster —
silently hitting localhost is the confusing failure mode.
# The normal case: set it once, then no flag on any command. A bare hostname is
# assumed https, so a Route host can be pasted straight in.
export EVENTBRIDGE_URL=eventbridge-kev1.apps.ykt1.hcp.res.ibm.com
python3 skills/eventbridge/eventbridge-cli.py run "Say hi"
# Or per command, when you mean a different target just this once
python3 skills/eventbridge/eventbridge-cli.py run "Say hi" \
--base-url http://eventbridge.127.0.0.1.nip.io:30080 # kindPrecedence is --base-url > $EVENTBRIDGE_URL > http://127.0.0.1:8080, and
the CLI reports which of the three it used on every run, including the default:
· endpoint https://eventbridge-kev1.apps.ykt1.hcp.res.ibm.com (from $EVENTBRIDGE_URL)
· endpoint http://127.0.0.1:8080 (from default)
That line used to be printed only when the endpoint was not the default, which is backwards: the silent case was the one that needed announcing. A run that quietly went to localhost reads exactly like "the agent never replied".
There is no discovery step, by design. The CLI is pure HTTP — it never invokes
kubectl, and neither should an agent driving it (SKILL.md says so explicitly, and a
test asserts the CLI cannot start a subprocess at all). If the endpoint is wrong or
down it says so and exits 2 with the fix, so nothing needs to pre-check /healthz:
✖ cannot reach EventBridge at http://127.0.0.1:8080 ([Errno 61] Connection refused)
endpoint came from: default
No endpoint was given, so this used the laptop default. Point it at the
right EventBridge — nothing here discovers one for you:
export EVENTBRIDGE_URL=https://<eventbridge-host> # whole session
… --base-url https://<eventbridge-host> # one command
Every run echoes its own command first, shell-quoted and pasteable, so a transcript can be replayed by hand and a wrong flag is visible where it was used rather than in the results:
$ python3 skills/eventbridge/eventbridge-cli.py group run --template 'Say Hello {n}' --count 100 --label hello-100 --base-url https://eventbridge-kev1.apps.ykt1.hcp.res.ibm.com
EVENTBRIDGE_ECHO=0 silences it; a user:pass@ in any URL argument becomes ***@
before printing.
Submit, then poll — don't block. A tool call (or a CI step) shows its output when
the command exits, so one long watching command reports nothing for minutes and then
everything at once, or gets killed at a timeout and reports nothing at all. Submit with
--no-watch, which returns in ~2 s with every correlationid, then check each one in its
own short bounded call:
$CLI runmany "Say hello." "Say world." --label agent1 --label agent2 --no-watch
$CLI watch sweet-crab-0956 --timeout 30 # seconds, not minutes
$CLI group status <gid> # one-shot counts, no waitingStdout is line-buffered explicitly, so partial progress survives a kill rather than
dying in an 8 KiB pipe buffer. And don't append 2>&1: it does nothing for latency and
throws away whether a line was progress or ✖ cannot reach EventBridge.
The skill lives in the repo at skills/eventbridge/ and is exposed to Claude
Code through a symlink from the user's skills directory, so there is exactly one copy
to edit and it is versioned with the code it drives.
Two agents at once — and why the order matters:
python3 skills/eventbridge/eventbridge-cli.py runmany \
"Say hello. One word." "Say world. One word." \
--label agent1 --label agent2runmany POSTs every prompt before it watches any of them, so N requests land on
the topic together, KEDA sees lag N and scales to N pods. Two sequential run calls
would each finish before the next was submitted — lag would never exceed 1, one pod
would serve both, and nothing would scale. Observed on ykt1:
· endpoint https://eventbridge-kev1.apps.ykt1.hcp.res.ibm.com (from $EVENTBRIDGE_URL)
▸ agent1 grumpy-beetle-8286 · https://…/v0/agents/grumpy-beetle-8286
▸ agent2 gentle-manatee-0993 · https://…/v0/agents/gentle-manatee-0993
· 2 agent(s) submitted together — KEDA should scale to 2 pod(s)
agent1 ASSISTANT: MOCK-REPLY[Say hello. One word.]
agent2 ASSISTANT: MOCK-REPLY[Say world. One word.]
✔ 2/2 agent(s) finished
spec_replicas=1 pods=0 # KEDA activates
spec_replicas=2 pods=2 # then scales to the lag
Both turns produced their own ntfy notification.
Setup needed before running the skill: just the endpoint. The CLI is
stdlib-only (no venv), NTFY_TOPIC is a property of the deployment rather than of
this client, and mock-vs-real is decided by the EventRunner deployment. kubectl is
only needed if you want to watch pods.
Two URLs, easily confused. --base-url / EVENTBRIDGE_URL is client side —
where the CLI sends requests. EVENT_BRIDGE_PUBLIC_BASE_URL is set on the
deployment and is what EventBridge advertises in HTML links and ntfy actions.
Changing one does not change the other.
Against the test and kind overlays every reply is MOCK-REPLY[...] — those
overlays mount no credential, so mock mode is auto-selected. That is expected.
Two runs, back to back, that show the one behaviour people get wrong when they first see this: whether a run is part of a batch changes how many notifications your phone gets. Three loose agents send three notifications. A hundred agents in a batch send two. Run them in this order — the contrast is the demo.
Everything below was executed on ykt1 on 2026-09-23 against the test overlay (mock
agents, so it costs no tokens). The numbers in the output blocks are measured, not
illustrative.
cd examples-aslom/eventing
CLI="python3 skills/eventbridge/eventbridge-cli.py"
# Point the CLI at the cluster once. Everything below then needs no --base-url, and
# the CLI echoes `(from $EVENTBRIDGE_URL)` on every run so the target is never in doubt.
export EVENTBRIDGE_URL=eventbridge-kev1.apps.ykt1.hcp.res.ibm.com
# The baseline to compare against afterwards. An ntfy topic is a capability, so this
# reads your own topic — substitute yours.
curl -s "https://ntfy.sh/$NTFY_TOPIC/json?poll=1&since=all" | wc -lOnly the pod-watching step later on needs kubectl (and a
KUBECONFIG=…/config-ykt1); the CLI itself never touches a cluster API.
Ask the skill for it in words:
on ykt1, run agent1 to say hello, agent2 to say world and agent3 to say again, one word each
which it turns into exactly one command — runmany, never three run calls:
$CLI runmany "Say hello. One word." "Say world. One word." "Say again. One word." \
--label agent1 --label agent2 --label agent3$ python3 skills/eventbridge/eventbridge-cli.py runmany 'Say hello. One word.' 'Say world. One word.' 'Say again. One word.' --label agent1 --label agent2 --label agent3
· endpoint https://eventbridge-kev1.apps.ykt1.hcp.res.ibm.com (from $EVENTBRIDGE_URL)
▸ agent1 patient-whale-1324 · https://…/v0/agents/patient-whale-1324
▸ agent2 bitter-leopard-8584 · https://…/v0/agents/bitter-leopard-8584
▸ agent3 cold-puma-7650 · https://…/v0/agents/cold-puma-7650
· 3 agent(s) submitted together — KEDA should scale to 3 pod(s)
agent3 ASSISTANT: MOCK-REPLY[Say again. One word.] agent3 ✔ done · 1.4s · 20→32 tokens
agent1 ASSISTANT: MOCK-REPLY[Say hello. One word.] agent1 ✔ done · 1.4s · 20→32 tokens
agent2 ASSISTANT: MOCK-REPLY[Say world. One word.] agent2 ✔ done · 3.9s · 20→32 tokens
✔ 3/3 agent(s) finished
What to verify — exactly 3 notifications, each pointing at its own page:
title : agent cold-puma-7650 · 1.4s
click : https://…/v0/agents/cold-puma-7650
body : ❓ Say again. One word. 💬 MOCK-REPLY[Say again. One word.]
title : agent patient-whale-1324 · 1.4s click: …/v0/agents/patient-whale-1324
title : agent bitter-leopard-8584 · 3.9s click: …/v0/agents/bitter-leopard-8584
And no batch exists — that is the half of this part that is easy to skip:
for c in patient-whale-1324 bitter-leopard-8584 cold-puma-7650; do
curl -s -o /dev/null -w "$c HTTP %{http_code}\n" "https://$EVENTBRIDGE_URL/v0/agents/$c"
curl -s "https://$EVENTBRIDGE_URL/v0/agents/$c" | grep -c '<div class="groupback">' # 0
curl -s "https://$EVENTBRIDGE_URL/v0/agents/$c/turns" | grep -o '"groupid": *[^,]*' # null
done
curl -s -H 'Accept: application/json' "https://$EVENTBRIDGE_URL/v0/groups" # count unchangedpatient-whale-1324 HTTP 200 · groupback divs: 0 · groupid: None
bitter-leopard-8584 HTTP 200 · groupback divs: 0 · groupid: None
cold-puma-7650 HTTP 200 · groupback divs: 0 · groupid: None
groups: 15 (15 before, so no group was created)
Three notifications for three agents is the right behaviour at this scale, and the reason batches exist: it does not survive being multiplied by 100.
The same watcher as Part 2 (below) shows KEDA following the lag exactly — and catches a
trap worth seeing once, at t=16s:
t=6s replicas=1 running=0 # KEDA activates
t=8s replicas=3 running=0 # scales to the lag: 3 requests, 3 pods
t=12s replicas=3 running=1
t=16s replicas=0 running=2 # ← spec.replicas is 0 while two pods are still running
t=20s replicas=0 running=0
spec.replicas == 0 does not mean no consumer is running: a pod lives until it
exits or terminationGracePeriodSeconds elapses, and until then it is still a group
member consuming from the topic. Any "is it idle yet?" assertion has to require zero
replicas and no surviving pod.
The simplest phrasing that gets there — no mention of groups, templates or counters:
on ykt1, run a batch of 100 agents, each saying Hello N
"batch" (or "as a group", or "100 agents") is what selects group run; "each saying
Hello N" is what becomes the template. One command, and it monitors itself:
$CLI group run --template "Say Hello {n}" --count 100 --label hello-100-demo{n} is 1-based ({i} is 0-based, --start shifts it). Do not pass 100 positional
prompts — the CLI echoes the first and last generated prompt so the pattern is
reviewable before anything runs, which 100 shell-quoted arguments are not. Watching is
the default, so progress lands in the transcript with no second command:
$ python3 skills/eventbridge/eventbridge-cli.py group run --template 'Say Hello {n}' --count 100 --label hello-100-demo
· endpoint https://eventbridge-kev1.apps.ykt1.hcp.res.ibm.com (from $EVENTBRIDGE_URL)
▸ group hello-100-demo bold-hornet-2962 · https://…/v0/groups/bold-hornet-2962
all groups · https://…/v0/groups
· 100 agents submitted together (from template 'Say Hello {n}')
first: 'Say Hello 1' last: 'Say Hello 100'
0/100 done · 100 queued · 8s elapsed
4/100 done · 4 running · 92 queued · 24s elapsed · about 2 minutes left
41/100 done · 16 running · 43 queued · 45s elapsed · less than a minute left
88/100 done · 5 running · 7 queued · 1m00s elapsed · less than a minute left
100/100 done · 1m20s elapsed
✔ group complete · 100 finished · 0 failed · 1m20s
What to verify — exactly 2 notifications, both pointing at the group page:
title : group hello-100-demo started
click : https://…/v0/groups/bold-hornet-2962
body : 🚀 100 agent(s) submitted · 📎 bold-hornet-2962
title : group hello-100-demo · 100 done
click : https://…/v0/groups/bold-hornet-2962
body : ✅ 100 finished · 🎯 of 100 expected · ⏱ 1m20s · 🐢 slowest agent 79.6s
Not 100, and not 102: while a group is open its members' own notifications are suppressed, keyed on membership rather than on the group still being open — so a redelivered request whose duplicate terminal event arrives after completion stays quiet too. Each member page gains a link back the other way:
curl -s "https://$EVENTBRIDGE_URL/v0/groups/bold-hornet-2962/status" | head -c 300
curl -s "https://$EVENTBRIDGE_URL/v0/agents/fresh-mole-0029" | grep -o '<div class="groupback">.*</div>'label hello-100-demo · finished/expected 100/100 · reason: all · members 100
<div class="groupback">← back to group <a href="/v0/groups/bold-hornet-2962">hello-100-demo</a> <code>bold-hornet-2962</code></div>
And the fan-out, in a second terminal while Part 2 runs:
while true; do
printf '%s replicas=%s running=%s\n' "$(date +%T)" \
"$(kubectl -n kev1 get deploy eventrunner -o jsonpath='{.spec.replicas}')" \
"$(kubectl -n kev1 get pods -l app.kubernetes.io/name=eventrunner \
--no-headers 2>/dev/null | grep -c Running)"
sleep 3
doneNote the selector is app.kubernetes.io/name=eventrunner; a plain app=eventrunner
matches nothing and reports a silent 0 throughout. Measured:
t=6s replicas=10 # the explicit HPA scaleUp policy jumps straight to the cap
t=12s replicas=10 running=1
t=30s replicas=10 running=8
t=33s replicas=0 # the batch drained
Reaching the cap in one step rather than climbing 1 → 5 → 10 over ~30 s is the
behavior.scaleUp policy in k8s/base/eventrunner-scaledobject.yaml doing its job:
KEDA handles 0 → 1 but delegates 1 → N to an HPA, whose default policy adds at most
max(100%, 4 pods) per 15 s.
maxReplicaCount is 10, under the topic's 12 partitions. Partitions are the
real ceiling — Kafka gives each partition to exactly one consumer in a group, and KEDA
enforces the same bound by default (allowIdleConsumers: false) — so the cap can be
lowered freely but raising it past 12 would mean raising partitions first, which is a
one-way door. With 10 consumers over 12 partitions, two of them simply own two
partitions each.
| Part 1 — three loose agents | Part 2 — a batch of 100 | |
|---|---|---|
| Command | runmany "…" "…" "…" |
group run --template … --count 100 |
| ntfy notifications | 3 — one per agent | 2 — group started, group finished |
| Each notification links to | that agent's own page | the group page |
| Group created | none (groupid: null) |
one, with 100 member rows |
| Wall clock | ~4 s | ~1m20s |
| Reproduced | twice, delta 3 both times | delta 2 |
Peak spec.replicas |
3 | 10 (the cap) |
If Part 2 sends more than two notifications, the suppression regressed. If a group
page 404s after a pod restart, check the log for [group-mirror] rebuilt N group(s) —
/data is emptyDir in this overlay, so group state is rebuilt from the responses
topic on every start.
Off by default. An ntfy topic name is a capability — anyone who knows it can read and publish your notifications — so it is never committed. Export it and deploy:
export NTFY_TOPIC=<your-ntfy-topic>
export NTFY_TOKEN=<token> # only for a protected topic
python3 scripts/k8s_deploy.py --yesRunning locally, NTFY_TOPIC alone is not enough — set NTFY_ENABLED=true too.
eventbridge/config.toml ships enabled = false, and the env override derives its own
default from whatever the file loaded (e("NTFY_ENABLED", "true" if cfg.ntfy.enabled else "false")), so an unset NTFY_ENABLED leaves ntfy off no matter what the topic is.
The startup banner is the check — it must say ntfy=on:
NTFY_ENABLED=true NTFY_TOPIC=$NTFY_TOPIC \
EVENT_BRIDGE_PUBLIC_BASE_URL=http://<lan-ip>:8080 \
.venv/bin/python -m eventbridge | grep 'ntfy='
# [eventbridge] bootstrap=… http=… ntfy=onWith ntfy=off nothing is sent and nothing is logged, which looks exactly like a
delivery failure. Also set EVENT_BRIDGE_PUBLIC_BASE_URL to a LAN address: the default
127.0.0.1 still delivers a fully readable notification, but the click-through and the
Continue… action button resolve to loopback and do nothing from a phone.
k8s_deploy.py stores it as Secret/eventbridge-ntfy, which the base Deployment
references with optional: true — listed after the ConfigMap so its
NTFY_ENABLED=true overrides the ConfigMap's "false". Confirm with:
kubectl -n kev1 logs deploy/eventbridge | grep ntfy # -> ntfy=on
curl -s "https://ntfy.sh/$NTFY_TOPIC/json?poll=1&since=10m"Each notification carries the prompt, the reply, a stats line and a click-through to
the correlation's HTML view at EVENT_BRIDGE_PUBLIC_BASE_URL.
Both images install ca-certificates and symlink /etc/ssl/cert.pem to it,
because the bundled standalone CPython looks for the former path while Debian writes
the latter. Without both, every outbound HTTPS call fails with
CERTIFICATE_VERIFY_FAILED and ntfy silently delivers nothing.
scripts/watch_topics.py is cluster-only — it locates a broker by the Strimzi
selector strimzi.io/name=my-cluster-kafka and aborts against a local broker with
[watch] ABORT no Kafka broker pod matching …. For a local run, point the Kafka CLI at
127.0.0.1:9092 directly (see Local).
Your kafka-get-offsets.sh pipeline is right — --topic does accept a regex in Kafka
4.x. But watch is not in the Strimzi broker image (nor is clear), so running it
inside the pod fails with bash: line 1: watch: command not found.
Run watch locally and let kubectl exec run only the Kafka tool. No remote shell
means no nested quoting, and awk runs on your machine:
watch -n 2 "kubectl -n kafka exec my-cluster-dual-role-0 -- \
bin/kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic '^(kev1-requests|kev1-responses)\$' \
| awk -F: '{s[\$1]+=\$3} END {for (t in s) print t, s[t]}' | sort"If you would rather stay inside the pod, loop by hand — printf replaces the missing
clear, and the awk program is double-quoted so \$1 survives the outer single quotes:
kubectl -n kafka exec my-cluster-dual-role-0 -- bash -c '
while true; do
printf "\033[H\033[J"; date -u +%H:%M:%SZ
bin/kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic "^(kev1-requests|kev1-responses)$" \
| awk -F: "{s[\$1]+=\$3} END {for (t in s) print t, s[t]}" | sort
sleep 2
done'Two caveats on what that number means. It sums end offsets, which is records ever
produced, not records still present — subtract --time -2 (earliest) once retention
starts expiring the head. And it says nothing about how much is done.
For the demo, scripts/watch_topics.py shows both, redrawing every 2 s in place:
python3 scripts/watch_topics.py # topics + group lag
python3 scripts/watch_topics.py --group <groupid> # + per-member running/queued
python3 scripts/watch_topics.py --once --no-color # one snapshot, for logstopics (end − earliest = records still retained)
TOPIC RECORDS PRODUCED PARTS RATE/s
kev1-requests 435 435 12 +8.0
kev1-responses 2331 2331 12 +24.0
consumer group kev1-eventrunner (active)
lag (work not finished) 62 RQ-1 defers the commit to the terminal event,
├─ claimed, in flight 62 so lag means unfinished, not merely unread
└─ not claimed yet 0
partitions owned by a member 12 / 12
Kafka stores committed offsets and never a live consumer's in-memory position, so there is no server-side "claimed and half-processed" flag to read. But two facts together give you the split:
kafka-consumer-groups.sh --describeprints a CONSUMER-ID per partition, or-when no live member owns it. So lag on an assigned partition is claimed work; lag on an unassigned partition is not claimed by anyone yet.- In this system lag already means "not finished", not "not read" — RQ-1 defers the offset commit until the agent's terminal event, so a record stays uncommitted for the whole run. That is also precisely the number KEDA scales on.
Captured live during an 80-agent batch, the two states are clearly distinguishable:
| moment | lag | claimed | not claimed | partitions owned |
|---|---|---|---|---|
| just after the POST | 80 | 0 | 80 | 0/12 |
| pods joined the group | 62 | 62 | 0 | 12/12 |
| draining | 16 | 16 | 0 | 12/12 |
For the exact running vs queued split, EventBridge is authoritative rather than
Kafka: it tracks per-member state instead of inferring it from offsets, which is what
--group <groupid> adds to the display. Kafka can tell you how much is unfinished and
whether anyone owns it; only the application knows which individual agents are mid-run.
| ykt1 | Kind | |
|---|---|---|
| e2e | 49/49 | 37/37 |
| POST → KEDA scales up | 1.7–3.0 s | 2.2–4.3 s |
| POST → pod Ready | ~14 s | — |
| POST → back to zero | 22 s (cooldown 5) | — |
| Concurrency (2–3 correlations) | peak 2–4 replicas | — |
Where the wake time actually goes, measured from a genuinely idle state:
POST ─1.7s─▶ KEDA decides to scale (pollingInterval: 5)
─14s──▶ pod Ready (image pull + free-threaded CPython init)
─22s──▶ back to zero (cooldownPeriod: 5, after the run finished)
KEDA's detection was never the slow part. The test and kind overlays use
cooldownPeriod: 5 (down from 30), which took the whole loop from 46.5 s to 22 s —
the 30 s had been an idle pod lingering, not a wake delay. Shortening it is safe
because RQ-1 commits the offset only after the terminal event, so lag stays ≥ 1 for
the whole run and KEDA cannot scale away a pod that is still working.
The remaining ~12 s is pod startup, which no KEDA setting affects. Pre-pulling the image onto nodes is the lever there.
RQ-4 assumed a cold start under 10 s; the scale-up decision beats that comfortably, while pod Ready does not on a node that has to pull a 245 MB image. The longest run observed was a real-agent turn stuck in 429 retry backoff for 300 s, which is also the best evidence for RQ-1: replicas stayed ≥ 1 for all 300 s.
Kind sets group.initial.rebalance.delay.ms: 0; ykt1 leaves it at 3000 ms and Phase 1
does not change a broker-wide setting on a shared cluster. So Kind timings are not
comparable to cluster timings.
Still deferred to Phase 2: Job-per-request isolation for a per-run capability envelope, non-idempotent agent support, auth on EventBridge's HTTP surface, multi-replica EventBridge, and TLS to Kafka.
The long-form designs, build reports and full runbooks live in
docs/, which has its own index. They
are the authoritative references this file summarizes. The one-page summary of all of
them, for a reader who will not open the code, is in
docs/:
docs/eventing-lab-overview.md |
start here if you are not going to open the code — what each phase decided, what is built as opposed to designed, and the KubeCon commitment with its gaps |
docs/specs/0001-phase0-local-wire-contract.md |
the wire contract, the uuid5 session scheme, and the §4.5 per-correlation ordering guarantee |
docs/specs/0002-phase1-keda-kubernetes.md |
the KEDA scaling model, the §16 gaps, and §3.2 on the local Kind target |
docs/specs/0003-phase2-identity-on-the-event-path.md |
identity on the event path — and §8, the thirteen control claims a docs review disproved, with §8.5's five questions |
docs/specs/0004-phase3-tenancy-agents-triggers.md |
per-user isolation, AgentSpec, and event triggers — partly implemented, EB_TENANCY_MODE still defaults to single |
docs/specs/0005-phase4-enrollment-and-permission.md |
enrollment and human-in-the-loop approval — design only, with §2 the measured mechanism the rest rests on |
docs/reports/phase0-report.md |
what Phase 0 built, and the follow-ups it left |
docs/reports/phase1-report.md |
what Phase 1 built, measured results on both clusters, 28 findings, and what is still blocked |
docs/reports/phase3-report.md |
what Phase 3 has built so far, measured — and §5, which is explicit that multi does not work end to end |
docs/notes/kubecon-na-2026.md |
the KubeCon NA talk: the abstract's five demo beats, their status, the SPIRE decision, and the MVP cut |
docs/demos/phase0-demo.md |
the laptop runbook in full detail |
docs/demos/phase1-demo.md |
the Phase 1 runbook in full detail — local, Docker, Kind, OpenShift |
If something behaves surprisingly, check
docs/reports/phase1-report.md §4 first — most
surprises are already recorded there with their root cause.