Skip to content

feat(eventing): Phase 3 steps 1-4 + T5 — tenancy keys, per-user stores, declarative agents - #883

Open
mrsabath wants to merge 13 commits into
mainfrom
feat/eventing-phase3-tenancy-foundation
Open

mrsabath wants to merge 13 commits into
mainfrom
feat/eventing-phase3-tenancy-foundation

Conversation

@mrsabath

@mrsabath mrsabath commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Phase / scope

Phase Phase 3 — per-user isolation, declarative agents, triggers (DESIGN_PHASE3.md)
What this changes §9 rollout steps 1–4 plus T5: tenancy keys, TopicSet, the user registry, signed userkey/depth/agent, per-user stores, the ownership index, and AgentSpec.
Phase 3 tasks NOT here T6 (ensure_subscribed), T7 (owner-scoped reads), T8 (transcript auth), T9–T12, T14–T21. EB_TENANCY_MODE=multi does not work end to end until T6 — no response is consumed.
Phases it does not touch Phase 0/1's wire contract and KEDA model are unchanged, and single mode reproduces Phase 2 byte for byte — the 525 pre-existing test functions pin that. It extends Phase 2's identity work (#879) rather than replacing it.
Relationship to the other drafts #884 (Phase 2 signing, deployable) — no file overlap; both add EB_/ER_ variables, so whichever merges second re-runs test_manifests.py's classification check. #890 (Phase 2 docs) — both edit agentdocs/README.md; whichever merges second resolves one table row and one reading-order entry. #890 deliberately leaves the DESIGN_PHASE3.md row to this PR.

Implements agentdocs/DESIGN_PHASE3.md §9 rollout steps 1–4, plus T5 — the
foundation for per-user isolation. Extends the Phase 2 identity work (#879) rather than
replacing any of it.

The design is a 12-step, 22-task phase, far larger than Phase 0/1/2 were individually.
§9 singles out the first four steps as the part that is safe to merge while multi has
never been switched on anywhere: "the first four steps are refactors with tests, and
everything risky is behind a flag that defaults off."
T5 is the keystone that everything
from step 5 on depends on, so it is here too.

What landed

Task What
T1 shared/tenancy.py — userkey(), TopicSet, ntfy_topic()
T2 TopicSet threaded through the producer, responses consumer and both mirrors
T3 Caller, the user registry, ce_userkey stamped on requests
T4 SIGNED_ATTRS += userkey, depth, agent — one change, per §8.3
T5 Per-user stores, the global correlationid → userkey index, Minter uniqueness
T13 eventrunner/agentspec.py + build_cmd as a function of the spec
T22 The Secret-vs-ConfigMap rule pinned in test_manifests.py

Not implemented, deliberately: T6 (ensure_subscribed), T7 (owner-scoped reads), T8
(transcript auth), T9–T12 (capability keys, ntfy isolation, k8s_tenant.py), T14–T19
(triggers, fetched skills), T20 (Kafka ACLs), T21 (deletion).

The additive guarantee

EB_TENANCY_MODE=single is the default and reproduces Phase 2 byte for byte. Every
test that existed before this branch passes untouched
— 573 collected at the merge-base
1098094, all still passing here; three test fakes were widened to match a producer
signature, but no assertion was weakened or removed. (The "656" previously quoted here
was never a real count of anything — it is the figure retracted in Testing below.) That is the
check §9 step 2 asks for, and it is why the largest diff in the phase is also the least
risky.

The default AgentSpec is held to the same standard:
test_default_spec_reproduces_phase2_argv_exactly compares against a literal
transcription
of the pre-Phase-3 build_cmd, not a call into the current
implementation. Single-tenant mode also keeps its SQLite files exactly where Phase 2 left
them — directly in the bridge root, not under shared/ — so an upgraded deployment's
sessions and transcripts keep showing up instead of appearing to vanish.

Decisions worth a reviewer's eye

  • The digest in userkey is the security control, not decoration. _slug is lossy so
    keys stay readable in kubectl get kafkatopics; that is safe only because 32 bits of
    SHA-256 over the canonical form are appended. a.b@x.com, a_b@x.com and a-b@x.com
    all slug to a-b-x-com and must not merge. The issuer is inside the hashed bytes too —
    otherwise a second configured issuer impersonates the first.
  • Separate store FILES, not one table with a userkey column (§6.1). store.py has
    28 methods and every one touches tenant data; a WHERE userkey = ? remembered 28 times
    is one that will eventually be forgotten. Separate files make the mistake structurally
    unavailable — the connection is the tenant.
  • The ownership index is UNIQUE on correlationid alone (§2.6). (userkey, correlationid) is right inside a tenant's store and exactly wrong here: two tenants
    minting one id produce two distinct tuples, never conflict, and the collision passes
    silently. That constraint is also what lets sessionuuid stay unsalted.
  • An explicit topic list, not subscribe(pattern=…) (§3.2). A pattern is picked up by
    metadata refresh (5 min default), so a new user's first response can be published before
    anyone is subscribed — and with auto_offset_reset=latest it is never read, with no
    error anywhere.
  • A request cannot widen the tool policy. max_turns/model still come from the
    request (Phase 0 wire parameters); permission mode, tool lists and system prompt are
    spec-only.

Bugs found in self-review and fixed

Each was confirmed by executing the real code, and each has a regression test that fails
without the fix:

  1. /continue returned a WSGI 500 where the owning tenant could not be resolved —
    reachable via a deregistered user, a NULL submitter, or a mirror-backfilled
    correlation. Now a 503 (or 404) before anything is written.
  2. Eviction closed stores that callers still held. Any multi-call read path
    (get_html makes five calls) could have its store closed by a concurrent request for
    another tenant → ProgrammingError: Cannot operate on a closed database. Eviction now
    drops the reference and lets CPython close at the last holder. The first attempt at
    this fix only pinned the SSE path — which closed the easiest case to see and left the
    class open; the bug was in the eviction contract, not one caller.
  3. GroupMirror never learned about tenancy — restart-orphaned groups were settled
    against the shared store (so never settled at all, with silence as the symptom), and
    replayed pre-Phase-3 events, which carry no ce_userkey, put member rows in shared/
    while the group row sat in the tenant's store. That is the normal upgrade path.
  4. except Exception hid two things: a signature mismatch that silently stopped every
    group event from publishing, and a broken ownership index reported as "correlation ID
    space exhausted".

Two limitations stated rather than implied

  1. multi mode is not yet isolation anyone should present as such. Per-user stores
    exist and reads route to the owning tenant's store, but nothing checks that the
    caller is that owner — T7/T8 are not in, so reads, /continue and PUT /transcript
    are as open as Phase 2's. §10 is also blunt that even once those land, separately-named
    topics without Kafka ACLs are organisation, not isolation.
  2. GET /v0/groups now requires an authenticated caller in multi mode, because the
    list must be scoped to someone. §6.2's route table does not mention it; flagging
    because it is a route that was open in Phase 2 and is the HTML page's own poller.
    multi already refuses anonymous callers at resolve_caller, so the practical blast
    radius is small — but say so if you would rather it waited for T7.

Testing

  • 875 passed, 5 skipped at aeefb19: +245 unique test functions (525 at the
    merge-base 1098094 to 770), 880 collected with parametrisation. ruff check and
    ruff format --check clean.

    Skip count varies by host: 5 needs_kubectl always, plus
    test_graceful_shutdown.py where a sandbox denies subprocess spawn — so 5, 6 or 7
    skips are all consistent runs of the same tree.

    Two earlier versions of this line were wrong. The first said "656 baseline + 139
    new", which diffed collected counts across a moving baseline; the second
    (840/+217/845) was accurate for 6ebdd80 and went stale across two more rounds of
    fixes. IMPLEMENTATION_REPORT3.md is the figure of record — a PR body cannot stay
    right about a branch that is still moving.
  • Both services verified to start in every tenancy configuration; all startup refusals
    print readable messages rather than tracebacks; the on-disk layout verified
    upgrade-safe.
  • Test strength checked by mutation, not assumed. One gap found and closed: deleting the
    LRU's pinning check passed every test, because they asserted object identity rather than
    the notification path that is the rule's actual purpose. Removing it now fails 4 tests.

Review round 1 (#883, @aslom) is addressed in 6ebdd80: five must-fixes, including a
single-mode regression where the ownership index was never seeded from an existing store
(so exists() reported live correlation ids as free and a mint could reissue one), an
unauthenticated cross-tenant group mutation, agent missing from SIGNED_ATTRS while it
selects the tool-policy sandbox, and a userkey reaching a filesystem path join
unvalidated from an inbound Kafka header. Each reproduced before fixing; each has a
regression test verified by reverting its fix. Six design-doc contradictions corrected in
the same commit. Deferred, by agreement: the T22 subset check,
RequestsMirror/NtfyPublisher stores=, a handler-to-topic integration test, the
forget() tombstone decision, and the four phase documents.

Draft because this is the first slice of a 12-step phase — review comments here should
shape T6/T7/T8 before they are written.

Design: eventing/agentdocs/DESIGN_PHASE3.md (#881). Reading order for review: §9 for
why this scope, §2.3 for why the key ends in a hash, §6.1 for the per-user store trade,
§2.6 for the uniqueness guarantee.

🤖 Generated with Claude Code

…pecs

DESIGN_PHASE3.md T1 and T13, the two tasks with no dependencies. Nothing here
changes behaviour: `single` tenancy is the default everywhere and the `default`
AgentSpec reproduces Phase 2's argv byte for byte.

shared/tenancy.py (T1)
  `userkey(issuer, userid)` is the only place an identity becomes a name. The
  slug is deliberately lossy so keys stay readable in `kubectl get kafkatopics`;
  that is safe only because 32 bits of SHA-256 over the canonical form are
  appended, so two identities that slug identically still differ. The issuer is
  inside the hashed bytes as well as the prefix — without it, configuring a
  second issuer is a way to impersonate an identity on the first. The digest
  input is joined with \x1f so the issuer/userid boundary is unambiguous.

  Canonicalisation is per-issuer and minimal: GitHub folds case (and must agree
  with `ghauth.is_allowed`, which a test pins), email lower-cases only the
  domain per RFC 5321, static is byte-exact. Erring toward two tenants for one
  human is wasteful; the other direction is a leak.

  `TopicSet` is the one place §3.1's layout is written down, and refuses to name
  a topic without a userkey in multi mode rather than falling back to a shared
  one. `ntfy_topic` is HMAC-derived so the name carries no identifier (§4.1).

eventrunner/agentspec.py (T13)
  One TOML file per agent, carrying the tool policy that §7.4 depends on — which
  is why §9 puts this before triggers. Validation refuses rather than coerces: a
  policy that silently drops the clause it could not parse would hand a
  trigger-driven agent the tool the operator meant to remove. A missing
  non-default agent is an error event, never a silent fallback to `default`.

  `build_cmd` takes a spec. The request still wins on `max_turns`/`model` (Phase
  0 wire parameters), but the tool policy, permission mode and system prompt are
  spec-only: a request that could widen the sandbox is a request that can escape
  it.

Tests: 105 new, 656 total passing, no existing test modified. The argv-equality
test compares against a literal transcription of Phase 2's `build_cmd` rather
than a call into the current one, so it catches a regression instead of
following one.

The `claude --help` flag assertion covers only the flags Phase 3 adds.
`--max-turns` is supported but undocumented in `--help` on 2.1.270, so asserting
the pre-existing flags would fail against a working deployment.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…igned tenancy

DESIGN_PHASE3.md T2, T3, T4 and T22. Still no behaviour change in the default
configuration: `EB_TENANCY_MODE=single` is the default, and the 656 tests that
existed before this branch all pass untouched. That is the property §9 asks steps
1-4 to preserve — refactors with tests, everything risky behind a flag that is off.

T2 — TopicSet threaded through (§3.2)
  One set, built once in `__main__` and handed to the producer, the responses
  consumer, both mirrors and the config, so §3.1's layout appears exactly once.
  `publish_request` takes a `userkey` and resolves the topic from it; one producer
  can send to any topic, so there is no second producer and no connection per user.

  The explicit-topic-list option, not `subscribe(pattern=...)`. A pattern is picked
  up by metadata refresh (5 min by default), so a new user's first response can be
  published before anyone is subscribed and with `auto_offset_reset=latest` it is
  never read — the page stays empty and no error appears anywhere. The blocking
  `ensure_subscribed` that closes the remaining race is T6.

  GroupMirror takes the full topic list directly: it is one-shot at startup, so
  there is no new-user race to lose, and a tenant whose topic is not provisioned
  yet is skipped with a log line rather than costing everyone their group history.

T3 — the registry and Caller (§2.4, §2.5)
  `Caller` carries the tenancy key so no caller derives it twice. `submitter_iss`
  is deliberately not the same as `issuer`: a static identity needs an issuer for
  its key to separate from a GitHub login of the same name, but Phase 2 made an
  absent `ce_submitteriss` mean "an operator typed this", and promoting it would
  upgrade an assertion into a verified claim.

  The registry refuses rather than skips — the opposite of `auth.parse_tokens`,
  because a skipped user is one whose topics exist and whose events have nowhere to
  go. A recorded userkey that disagrees with this build's derivation is a startup
  refusal naming both values, and two identities colliding on one key is refused,
  which is what covers §2.3's 32-bit birthday bound.

  `multi` mode refuses anonymous with 401 and an unregistered user with 403. Both
  disagree with the single-tenant defaults on purpose; `single` is byte-identical
  to Phase 2.

T4 — SIGNED_ATTRS += userkey, depth (§8.3)
  One change, both attributes, because adding one changes canonicalisation and a
  signer and verifier on different versions disagree about every signature. Both
  have to be covered rather than merely present: a mutable `userkey` lets anything
  with topic write access file events into another user's history, and a resettable
  `depth` is not a hop limit.

T22 — the Secret-vs-ConfigMap rule, pinned in test_manifests.py
  The secret-path variables are declared ahead of the tasks that read them (T8-T16)
  so the guard is in place when they land. The classification test only asserts the
  ConfigMap side against live code, and says so, rather than looking stronger than
  it is.

Known gap, stated rather than hidden: `/continue` resolves the owning tenant from
the recorded submitter so a resume reaches the right runner, but does not yet check
that the caller IS the owner — owner-scoped reads and `?k=` keys are §6.2/§4.4
(T7/T9, step 5). Until those land, `multi` mode's `/continue` is as open as Phase
2's. The lookup also cannot distinguish a GitHub `alice` from a static `alice`;
`multi` mode refuses both anonymous and static submissions, so GitHub is the only
issuer that reaches it today, and this moves to T5's global index when that exists.

Tests: 66 new (722 total), ruff clean. Verified both services start in every
tenancy configuration and that all four refusals print readable messages rather
than tracebacks.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…till design

The Phase 3 design doc said "nothing in this document is implemented yet" and the
agentdocs index said "Design only — not implemented". Both are now wrong in a way
that matters: a reader who trusts them will either re-implement steps 1-4 or assume
`EB_TENANCY_MODE=multi` gives them isolation it does not yet give.

So the status lines name the tasks that landed (T1, T2, T3, T4, T13, T22 = §9 steps
1-4) and say plainly that everything from step 5 on — per-user stores, owner-scoped
reads, transcript auth, `k8s_tenant.py`, ntfy isolation, triggers, fetched skills,
Kafka ACLs — is still design only.

The component README gains a Phase 3 configuration table covering only the six
variables whose code exists, with two notes that are the point of including it at
all: `multi` mode is not yet isolation anyone should present as such (the HTTP reads
and `/continue` are still as open as Phase 2's, because steps 5-8 are not in), and
adding a user is an operator action because EventBridge has no Kubernetes client.

Verified against the code rather than assumed: only `POST /v0/agents` and
`POST /v0/groups` call the auth path, so every GET, `/continue` and `/transcript`
is open in both modes today.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…rashing

Found reviewing the previous commit. In `multi` mode `_owner_userkey` returned
`None` both when no key was needed (single mode) and when the owning tenant could
not be determined. The second `None` flowed into `publish_request` ->
`TopicSet.requests(None)`, which raises `ValueError` by design — publishing a resume
to a shared topic would run it on another tenant's runner, under that tenant's
credential — but nothing caught it, so the request died as a WSGI 500 with a stack
trace instead of a status code.

Three ways to reach it, none of them abuse:

  * a user removed from the registry;
  * a `prompts` row whose `submitter` is NULL — the column is nullable and pre-dates
    auth, so these rows exist in any store that outlived Phase 2;
  * a correlation back-filled from the topic by `RequestsMirror`, which records no
    submitter at all.

`_owner_userkey` now returns `(userkey, unresolved_reason)` with exactly one set, so
the two `None` cases are distinguishable, and both `/continue` paths check it before
writing anything — no touched session row and no orphan prompt row claiming a turn
that never ran. The JSON route answers `503` naming what could not be resolved,
matching how §2.5 treats a user whose topics do not exist. The HTML form route
answers `503` with a plain-text body rather than redirecting: a silent `303` back to
the transcript page would look like the turn was accepted and then vanished, which is
the Phase 2 §6.1 symptom class this phase keeps trying to avoid.

Only reachable with `EB_TENANCY_MODE=multi`, so the default path was never affected.

13 new tests (735 total). Verified they are load-bearing by reintroducing the bug:
the four refusal tests fail without the guard and pass with it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
… index

DESIGN_PHASE3.md T5 (§6.1, §2.6), the keystone the rest of step 5+ depends on: T7
(owner-scoped reads), T8 (transcript auth), T15 (triggers) and T21 (deletion) are all
blocked on it. `EB_TENANCY_MODE=single` remains the default and reproduces Phase 2.

eventbridge/owner_index.py — the one deliberately cross-tenant table
  `correlationid -> userkey`, UNIQUE on `correlationid` **alone**. That is the part to
  get right: `(userkey, correlationid)` is the correct primary key inside a tenant's
  store and exactly the wrong constraint here, because two tenants minting one id
  produce two distinct tuples, never conflict, and the collision passes through
  silently — which is the failure the guarantee exists to prevent.

  It has to be cross-tenant: the lookup that decides which tenant owns a correlation
  must happen before a per-user store can be chosen. It is also what lets
  `sessionuuid = uuid5(NAMESPACE, correlationid)` stay UNSALTED (§2.6) — salting it
  would break every existing session for a collision this constraint removes.

  `owner_of` returns `(userkey, known)` because a bare `None` conflates "the shared
  tier owns this" with "never seen", and §6.2's 404 rule needs them distinguished.

eventbridge/store_registry.py — per-user stores, LRU-bounded
  Separate FILES, not one database with a `userkey` column. `store.py` has 28 methods
  and every one touches tenant data; a `WHERE userkey = ?` remembered 28 times is one
  that will eventually be forgotten, with a user reading someone else's conversation
  as the symptom. Separate files make the mistake structurally unavailable — the
  connection IS the tenant.

  Two connections per tenant in WAL mode (main + -wal + -shm) bounds this near 150-200
  tenants under a 1024 soft RLIMIT_NOFILE, hence `EB_MAX_OPEN_STORES=64`. A store with
  live SSE subscribers is PINNED: evicting one makes an open page stop updating with no
  error anywhere. If everything is pinned the cache exceeds its ceiling rather than
  breaking a viewer — over-budget degrades and is visible on /healthz; a dead stream
  does not recover.

Minter now consults the index (§2.6)
  One indexed SELECT per mint replaces the startup seeding loop. With N per-user stores
  the old loop meant N SQLite opens before the socket binds — 100 tenants x 10,000
  correlations for a guarantee one SELECT gives directly. The claim happens inside the
  retry loop, so a candidate lost to a concurrent worker retries instead of both callers
  believing they own it.

  `mint_for()` passes `userkey` only to minters that accept it, by signature inspection
  rather than by catching TypeError around the call — a working `mint()` can raise
  TypeError from its own body, and swallowing that would silently drop the ownership
  claim.

Routing
  Responses are filed by the event's OWN signed `ce_userkey`. One with no userkey in
  multi mode lands in `shared/` and increments `unattributed` (now on /healthz): never
  guessed into a tenant's store, never dropped. `/continue`, the reads, the group routes
  and the deadline sweeper all route through the index — the sweeper across every tenant,
  because a stalled batch belonging to an untouched tenant is exactly what deadlines are
  for and also the least likely to be in the LRU.

  This retires the submitter-derived owner lookup from the previous commit, and with it
  the limitation that it could not tell a GitHub `alice` from a static `alice`.

Single-tenant mode keeps its files exactly where Phase 2 left them — directly in the
bridge root, not under `shared/` — so an upgraded deployment's sessions and transcripts
keep showing up in the UI instead of appearing to vanish.

Two bugs found and fixed while building this:

  * **Eviction could return a closed store.** With every older entry pinned, the LRU
    took the store it had just opened and handed the caller a closed one — surfacing
    later as `ProgrammingError: Cannot operate on a closed database` from somewhere that
    looks unrelated to caching. Fixed with `protect=`, with a regression test.
  * **`except Exception` hid a signature mismatch.** Adding `userkey=` to
    `publish_group_event` made every group event stop publishing, and the broad handler
    around it turned that into a batch that silently never completed. TypeError now
    re-raises at both call sites.

`Store.close()` added (it had none), and it wakes subscribers first so a viewer blocked
on `Event.wait()` notices shutdown instead of hanging to its own timeout.

Tests: 49 new (785 total), ruff clean. Verified both modes start and that the on-disk
layout is upgrade-safe.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
… them

Found reviewing T5. `StoreRegistry` eviction called `Store.close()`, which created a
use-after-close race on every multi-call read path: a handler resolves a store, and
while it is still making calls a *concurrent request for a different tenant* pushes
the cache over `EB_MAX_OPEN_STORES` and closes it underneath. The next call fails with

    sqlite3.ProgrammingError: Cannot operate on a closed database

from a line that looks nothing to do with caching. `get_html` alone makes five calls on
one store, so the window is wide rather than theoretical, and reproducing it needs only
`max_open=1` and two tenants.

Eviction now drops the registry's reference and lets CPython close the connections when
the last holder goes away — exactly the lifetime that is safe. File descriptors are
reclaimed slightly later than an explicit close; that is the right trade, because the
ceiling is a soft budget while a closed connection under an in-flight request is a 500.

`_sse_generator` additionally now subscribes BEFORE the replay read. Pinning is what
keeps a subscribed store in the cache so later readers get the same object and see the
subscriber's notifications, and the old order left the store unpinned between
`_store_of()` and `subscribe()`. Reordering is harmless: the Event only ever means
"there may be something new", every read is `since_seq=sent`, and an already-set Event
costs one extra query.

Worth noting what the first fix attempt got wrong, since it is the more interesting
mistake: pinning only the SSE path would have closed the case that was easiest to see
while leaving every ordinary read exposed. The bug was in the eviction contract, not in
one caller.

Two regression tests, one asserting an evicted-but-held store stays usable for both
reads and subscribe, one pinning the subscribe-before-replay order. 787 passing.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
… failures

Three bugs from an adversarial review of T5. All three were confirmed by driving the
real code, and all three are multi-tenant-only — the default `single` path is unaffected.

**1. Restart-orphaned groups were never settled (`_settle`).** It called
`maybe_complete(gid)` with no `userkey`, so it looked in the SHARED store for a group
that lives in a tenant's store, found nothing, and returned False. Every group a restart
left unfinished stayed unfinished forever, and the failure mode is silence — which §21.6
calls the worst one. The mirror now resolves the owner per group from the ownership index.

**2. Replayed member events landed in the shared store.** `on_member_event` reads
`ce_userkey` off the event, and any response published *before* Phase 3 has none — so
member rows went to `shared/` while the group row sat in the tenant's store, leaving the
group's counts permanently wrong and `_decorate_member` reading from a third place.
Pre-existing Kafka history is exactly what this mirror exists to replay, so that is the
normal upgrade path, not an edge case.

The mirror now back-fills a missing `userkey` from the index. It only ever FILLS: an event
carrying its own `userkey` keeps it, because that attribute is signed (§2.6) and the index
is not authoritative over a signed assertion.

Both of these had one root cause — the mirror knew about per-user topics but not about
which tenant a replayed group belonged to.

**3. A broken index surfaced as "correlation ID space exhausted".** `mint()` caught
`Exception` around `claim` to treat a lost race as a retry, so a genuinely broken index
(disk full, database locked, schema missing) was retried 2000 times and then reported a
message that sends the reader to the word lists rather than the database. Now catches
`Collision` specifically; everything else belongs to the caller.

Also closes a test-strength gap the review found: deleting the `_has_subscribers` pinning
check entirely still passed every `test_store_registry.py` test. The existing tests
asserted object identity and counters, not the notification path that is the actual reason
for the rule — now that eviction only drops a reference, a held store keeps working, so
what actually breaks is that the *writer* gets a new Store whose subscriber map is empty
and the viewer is never notified. The new test asserts that end to end; removing the
pinning check now fails 4 tests instead of 0.

Tests: 8 new (795 total), ruff clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…ve you

The status lines said steps 5-12 were design only; T5 (per-user stores and the global
ownership index) is now in the tree, so they were wrong in the direction that matters —
a reader would either re-implement it or, worse, read "per-user stores" as "isolation".

The distinction the docs now draw explicitly: each tenant's sessions, responses and
transcripts DO 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 auth (T8) are not implemented. So `multi` mode's reads,
`/continue` and `PUT /transcript` remain as open as Phase 2's.

Also adds `EB_MAX_OPEN_STORES` to the component README's Phase 3 table with the
descriptor arithmetic that explains the default, and to `test_manifests.py`'s
classification list so T22's "every Phase 3 variable is on one side of the
Secret-vs-ConfigMap rule" check keeps covering it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath mrsabath changed the title feat(eventing): Phase 3 steps 1-4 — tenancy keys, per-user topic plumbing, declarative agents feat(eventing): Phase 3 steps 1-4 + T5 — tenancy keys, per-user stores, declarative agents Oct 2, 2026

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Careful, well-reasoned work — 37 files, +4618/−153, implementing §9 steps 1–4 plus T5,
T13 and T22, with four fix(...) commits for defects found during the work and docstrings
that argue for their decisions rather than restating them. The two modules the design is
most specific about are faithful to it: userkey() puts the issuer inside the hashed bytes
with a \x1f separator for the right reason, and owner_index.py declares
correlationid TEXT PRIMARY KEY with a comment on why it must not be
(userkey, correlationid) — the #881 correction landing exactly as specified.

Requesting changes for five things, two of which I would not want merged in any form
and one of which affects the default configuration.

The single-mode regression is the serious one. __main__.py deletes the
Minter seeding loop and nothing backfills its replacement — OwnerIndex has no
seed-from-store path. On the first start after an upgrade the index is empty while
sessions.sqlite is full, so exists() reports ids as free that are in use. The space is
50 × 50 × 10,000 = 25M, so with ~1,000 existing correlations a mint has ~1-in-25,000 odds
of reissuing a live id, and the result is upsert_session overwriting a session and the
new prompt appending to somebody's old conversation. Phase 2's odds were zero. This
contradicts the headline guarantee, and no test covers the state that causes it because
the fixtures build index and store together.

Then two that make multi incorrect rather than merely incomplete. create_group
passes userkey to submit_members but not to groups.create(), so the group row lands
in the shared store while its members land in the tenant's — the batch reports 0 members
forever and never completes, and a retried POST returns members: []. And
close_group/cancel_group perform no authentication at all (_caller is called in only
three places in that file, neither of them these), then resolve any tenant's group from the
global index and mutate it. T7 covers reads staying open; it does not cover unauthenticated
cross-tenant mutation, which is new here.

Two more. agent is absent from SIGNED_ATTRS while ce_agent selects the AgentSpec
that supplies --permission-mode and the tool allowlists — so a forged attribute widens
the sandbox and the signature still verifies, which makes the signed configuration worse
than the unsigned one. And userkey reaches a filesystem path join unvalidated, from an
inbound Kafka header: .. escapes users/ into another tenant's store or out of the tree
entirely, verified by construction. tenancy.py is the only place that knows the key's
shape and exports no validator, which is the root of both.

Everything above was reproduced against 16158e50 rather than inferred. Six suggestions
and four nits follow, and two documentation comments.

On design conformance, which was asked for: the code is not fully reflected in
DESIGN_PHASE3.md.
Six gaps, two of them contradictions — §6.1 still places
single-tenant stores in shared/ where the code uses the bridge root, and still asserts
"Closing is safe" about an LRU that commit 49aed05 proved unsafe. owner_index.py
appears in the document exactly once, in the status header this PR adds. Inline on that
header.

I have also set out what IMPLEMENTATION_REPORT3.md and README_PHASE3.md should contain,
and why the two Phase 2 documents are still owed — DESIGN_PHASE2.md §6 is a
test-results-and-findings report filed inside a design document, and its §7 "Running it?"
points at README_PHASE1.md for a phase that added the device flow, the approved-user
list, the break-glass path, a 300 s revocation window and the keyset rollout.

Author: mrsabath (MEMBER — maintainer, normal review posture)
Areas reviewed: Python (24 files), tests (12), Markdown (3). No YAML, Helm/K8s, Dockerfile, shell or CI in the diff.
Agent/IDE config (.claude/.vscode): none — §3.5a gate run for both +++ b/ and rename to forms, no match.
Secrets scan: clean. No CI workflow, dependency manifest, pyproject.toml, uv.lock or Dockerfile touched.
Commits: 8, every one Signed-off-by, DCO passing, all conventional prefixes.
CI status: all 11 checks passing — which is worth noting against the findings above: the suite cannot see the two multi defects, because every multi-mode group test calls GroupService.create directly with the userkey the handler fails to supply, and test_continue_tenancy.py mocks the producer so no topic string is ever asserted.

Checked and clear

  • "No assertion was weakened" — true. test_auth.py and test_groups.py have one identical hunk each, widening a fake producer's publish_group_event signature and recording the new argument. Nothing removed or relaxed; test_manifests.py is purely additive.
  • Hidden skips — none. The only skip is test_agentspec.py's disclosed skipif(shutil.which("claude") is None). Worth knowing that this is the test pinning §5.5's CLI flags, so it does not run in CI.
  • Test strength generally — test_tenancy.py's collision corpus, test_owner_index.py's test_the_constraint_is_on_correlationid_alone and its concurrency test, test_store_registry.py's pinning and eviction regressions, and test_agentspec.py's literal Phase-2 argv transcription are all real and would fail on a regression. The two gaps are the ones noted inline.
  • §8.3's coordination warning is narrower than written, in your favour. canonical() omits absent attributes and sorts by name, and nothing sets depth yet, so for every event a default deployment signs today the canonical form is byte-identical across this change. The two services do not have to move together unless multi is on.
  • The GET /v0/groups question in the PR body is already answered by §6.2, which has that row — and also commits to filtering the list by owner, which this PR does not yet do.
  • The test arithmetic needs reconciling: the body says "656 baseline + 139 new", but this PR adds 192 def test_ functions and removes none, and parametrisation only raises the collected count.

Comment thread eventing/eventbridge/store_registry.py
Comment thread eventing/eventbridge/owner_index.py Outdated
Comment thread eventing/eventbridge/owner_index.py Outdated
Comment thread eventing/eventbridge/owner_index.py Outdated
Comment thread eventing/shared/tenancy.py
Comment thread eventing/tests/test_mirror_tenancy.py
Comment thread eventing/eventbridge/handlers.py
Comment thread eventing/eventbridge/config.py Outdated
Comment thread eventing/eventrunner/runner.py Outdated
Comment thread eventing/tests/test_tenancy.py Outdated
Every finding reproduced against 16158e5 before fixing, and every fix has a regression
test in `tests/test_review_fixes.py` that fails without it (verified by mutation, one
fix at a time). They are filed together because they share a cause worth naming: all
five were invisible to 192 test functions, because the fixtures assembled the
collaborators by hand with the arguments the production call sites fail to supply.

**1. `single`-mode regression: the ownership index was never seeded.** The serious one,
and against the exact guarantee this phase claims. Dropping Phase 2's `Minter` seeding
loop left nothing to populate its replacement, so on the first start after an upgrade
`owners.sqlite` is empty while `sessions.sqlite` is full and `exists()` reports live ids
as free. With ~1,000 existing correlations a mint has roughly 1-in-25,000 odds of
reissuing one, and then `upsert_session` overwrites a session and the new prompt appends
to somebody's existing conversation. Phase 2's odds were zero.

`OwnerIndex.seed_from()` plus a one-time backfill from every store at startup.
§2.6 is right that per-start seeding is the wrong trade; it accidentally argued away the
one-time backfill too, and §6.1a now records the distinction.

**2. `create_group` did not pass the userkey.** `submit_members` got it and
`groups.create()` did not, so in `multi` the group row and the groupid's owner claim
landed in the shared store while every member row landed in the tenant's: 0 members
forever, `maybe_complete` never fires, the batch never completes and never notifies. A
retried POST with an Idempotency-Key read the tenant store `create` never wrote and
returned `members: []` — worse than a second batch, because it looks like success.

**3. `close_group`/`cancel_group` were unauthenticated cross-tenant mutations.** Neither
called `_caller`; both resolved any tenant's group from the global index and acted on it.
T7 covers *reads* staying as open as Phase 2's — Phase 2 had no tenants to cross and no
cross-tenant mutation anywhere, so this was a capability being introduced, not an openness
preserved. Now gated by `_authorize_mutation`, answering `404` for another tenant's group
per §6.2's enumeration rule. Single-tenant mode is untouched.

**4. `agent` was outside `SIGNED_ATTRS`** while `ce_agent` selects the AgentSpec that
supplies `--permission-mode` and the tool allowlists — §5.1's "sandbox". A forged value
widened the sandbox and the signature still verified, making a signed deployment worse
than an unsigned one because an operator believes it is attested. Added in the same
change as `userkey`/`depth`, per §8.3's one-canonicalisation-break rule.

**5. `userkey` reached a filesystem path join unvalidated**, from an inbound Kafka
header, and `Store.__init__` calls `mkdir(parents=True)` — so `..` created and wrote to a
directory inside another tenant's store, or outside the tree. `tenancy.is_valid_userkey()`
is the gate, exported from the module that is "the ONLY place an identity becomes a name"
so a consumer can check the shape the producer guarantees. An invalid key is treated as a
missing one — `shared/` plus `unattributed` — so a forgery is counted and visible rather
than acted on. Note it is *truthy*, so the pre-existing `not userkey` check let it past.

Also from the review:

- **A known-but-unowned correlation 500'd on `/continue`.** `owner=None,
  unresolved=None` is falsy, so the handler proceeded, wrote a prompt row, then
  `TopicSet.requests(None)` raised — a traceback with an orphan row already committed, so
  the conversation showed a turn that was never submitted. Reading and publishing have
  different requirements, so `_publishable_owner` is now separate from `_owner_userkey`:
  shared-tier data stays readable, and a turn for it is refused before anything is written.
- `claim()` uses `ON CONFLICT DO NOTHING` + a follow-up read, so `Collision` is the only
  way a conflict presents whether or not two writers race the in-process lock — the bare
  `INSERT` would have raised `IntegrityError`, which `Minter.mint` deliberately does not
  catch.
- `created_utc` at millisecond precision (matching `ce.now_iso()`) with a `rowid`
  tiebreaker; at second granularity a 100-member batch had arbitrary order among its rows.
- A bad `agent` name is a `400` on the call that made the mistake, not a `202` followed by
  an async `phase=error` the submitter may never read.
- `max_turns: 0` is no longer silently rewritten to the spec's value while the spec path
  rejects zero.
- `EB_TOPIC_PREFIX` validated (it is the one topic-name component `_slug` never sees, and
  `.`/`_` there reproduce the JMX metric collision) and `EB_MAX_OPEN_STORES` refuses
  readably instead of raising a bare `ValueError`.
- Dropped a flaky assertion: `"gh" not in` 26 characters of base32 fails ~2.4% of the
  time by construction, and the `"mrsabath" not in` line above it is the real property.
- `_canonicalise`'s docstring no longer claims static identities are byte-exact when the
  `@` branch catches address-shaped ones first.

Design doc, where the code had disproved it (§9's "the code is the authority" rule):
§6.1's layout corrected to put single-tenant stores in the bridge root; "Closing is safe"
replaced with what commit 49aed05 established, including that the first fix attempt only
pinned the SSE path and left the class open; new §6.1a documents `owner_index.py`; §2.6's
"one new extension attribute" corrected to four, with which three are signed and why; §3.1
records the single-mode `events`/`dead` names. The status header now warns that `multi`
does not work end to end until T6 — no response is consumed, which is more useful than
"not yet isolated".

Tests: 45 new (840 total), ruff clean.

Still outstanding from the review and deliberately not in this commit: the T22 subset
check, `RequestsMirror`/`NtfyPublisher` `stores=`, the handler-to-topic integration test,
the `forget()` tombstone decision, and the four documents.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath

mrsabath commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Thanks — this was a genuinely useful review. All five must-fixes are in 6ebdd80, and I
reproduced each one against 16158e50 before touching it rather than taking the report on
trust. Every fix has a regression test in the new tests/test_review_fixes.py, and I
verified each test is load-bearing by reverting its fix one at a time.

The single-mode regression was the one that mattered, and your reasoning about it was
exactly right. I confirmed it directly: a store with 50 pre-existing sessions plus a fresh
index reports 50 of 50 live ids as free. OwnerIndex.seed_from() plus a one-time
backfill from every store at startup closes it.

Worth recording why I walked into it, because the design contributed: §2.6 argues
convincingly that one SELECT per mint beats re-seeding a seen set on every start — and
in arguing that, it reads as though the index needs no seeding at all. Those are different
claims. §2.6 now distinguishes per-start seeding (correctly rejected) from one-time
backfill
(required for correctness), and new §6.1a documents the index properly.

Finding Fix
Index never seeded (single-mode regression) seed_from() + startup backfill from every store
create_group dropped userkey passes it; group row and members now colocate
close/cancel unauthenticated cross-tenant mutation _authorize_mutation, 404 per §6.2
agent outside SIGNED_ATTRS added alongside userkey/depth, one canonicalisation break
userkey → path join unvalidated tenancy.is_valid_userkey(); invalid ⇒ shared/ + unattributed

On the path traversal — I took your suggestion as written, including treating an invalid
key exactly like a missing one so the forgery is counted and visible rather than
rejected. Your note that an invalid key is truthy and therefore bypassed the existing
not userkey branch was the part I would have missed; the validator lives in tenancy.py
for the reason you gave, and there is a round-trip test asserting
is_valid_userkey(userkey(iss, uid)) over the adversarial corpus.

On close/cancel: I agree "T7 is not in yet" does not cover it, and your framing is the
right one — reads staying open preserves a Phase 2 property, whereas unauthenticated
cross-tenant mutation is a capability this PR would have introduced. Also fixed the
three-routes-disagree problem you noted; all of them now keep the second return value.

Also addressed from your suggestions and nits: the known-but-unowned /continue 500 (I
reproduced the orphan prompt row — reading and publishing turn out to need different
answers, so _publishable_owner is now separate from _owner_userkey, keeping shared-tier
data readable); ON CONFLICT DO NOTHING so Collision is the only way a conflict
presents; millisecond created_utc with a rowid tiebreaker; agent name validated at
the HTTP boundary; max_turns: 0; EB_TOPIC_PREFIX and EB_MAX_OPEN_STORES validation;
the _canonicalise static/@ docstring.

You were right about the flaky test, including the arithmetic. "gh" not in 26
base32 characters fails about 2.4% of the time by construction. Dropped — the
"mrsabath" not in line above it is the property §4.1 actually cares about.

And right about my test count. It is +217 unique test functions (525 → 742; 845
collected with parametrisation), not the 139 I claimed. I had been diffing collected
counts across a changing baseline. Corrected in the PR body.

On design conformance

Fixed in-tree, under §9's "the code is the authority where implemented" rule:

  • §6.1's layout — single-tenant stores are in the bridge root, not shared/, with the
    upgrade-safety reasoning that motivated it. I also fixed the same drift inside
    store_registry.py, whose module docstring reproduced the design's version while
    _dir_for did the opposite 40 lines below.
  • "Closing is safe" — replaced with what 49aed05 established, including the detail
    you singled out: the first attempt pinned only the SSE path, which fixed the visible
    case and left the class open. The bug was in the eviction contract, not one caller.
  • §6.1a — new, documenting owner_index.py: the correlationid-alone PRIMARY KEY,
    nullable userkey, Collision vs idempotent re-claim, owner_of's two-value return,
    the seeding requirement, and mint_for's honest degradation.
  • §2.6 — "one new extension attribute" → four, with which three are signed and why.
  • §3.1 — records the single-mode events/dead names.
  • Status header — now says multi does not work end to end until T6, because no
    response is consumed. You are right that this is a more useful warning than "not yet
    isolated", which implies a working-but-unisolated mode.

Two corrections accepted: §6.2 does have the GET /v0/groups row, so requiring an
authenticated caller is the design's own position and needs no dispensation — and it also
commits to filtering the list by owner, which I have noted for T7. And §8.3's coordination
warning being narrower than written is a useful thing to know.

Deliberately not in this commit

Your remaining suggestions, which I would rather do as a reviewable second pass than bury
in this one: the T22 subset-direction check (you are right that it cannot currently fail
for the reason it exists), RequestsMirror/NtfyPublisher missing stores= — which do
look like the sweep in 77807b6 not reaching them — the handler→topic integration test,
and the forget() tombstone decision. That last one I want to think about rather than
answer quickly, since the unsalted sessionuuid makes id reuse a cross-user transcript
resume, which is a worse consequence than the docstring implies.

The four documents I have not started. Your argument that the findings are freshest now is
the right one and I would rather write IMPLEMENTATION_REPORT3.md while the eviction and
except Exception findings are still attributable to specific commits, so I will take it
next unless you would rather see the code suggestions land first.

All 11 checks passing on 6ebdd80.

…etion

Round two of the review, covering what was deferred from 6ebdd80.

**T22 now checks the direction that enforces the rule.** `CONFIGMAP_VARS ⊆ found`
catches a stale entry and nothing else; the direction that catches a *new* variable
classified as neither is `found ⊆ CONFIGMAP_VARS ∪ SECRET_PATH_VARS`, which is the case
the test exists for. Scoped to Phase 3's variables — the 36 that pre-date §8.3 are listed
explicitly, with `test_the_baseline_list_is_still_accurate` keeping that list honest
rather than letting it silently widen the exemption.

Verified by deliberate failure, as asked: injecting an unclassified `e("EB_UNCLASSIFIED_X")`
read into `config.py` turns the test red with the variable named. Also replaced the
hardcoded ten-space indentation in the inlined-secret check with `\s*`, matching the
precedent elsewhere in the same file — the old pattern would miss the same variable
inlined in an initContainer, a sidecar or a deeper patch.

**`RequestsMirror` and `NtfyPublisher` now take `stores=`.** Both knew the per-tenant
topic names and still wrote to the shared store, which does look like the sweep in
77807b6 not reaching them. The mirror back-filled every prompt into `shared/`, so a
correlation whose request the bridge did not originate showed a BLANK PROMPT on its
owner's page — the exact gap the mirror exists to close. ntfy's `_last_prompt` and
`_last_assistant_text` read only the shared store, so a tenant's notification arrived
without the prompt or the reply, which §4.1 says is the content that lets a phone that
cannot reach EventBridge still see the result. Its group-existence check is routed too.

**A handler→topic integration test**, which the review argued was a better investment
than more unit tests on the pieces. It is: faking at `KafkaProducer` rather than at
`Producer` means the real handler, the real `Producer` and the real `TopicSet` all run,
and the assertion is *which topic the bytes went to*. Confirmed by reverting each fix that
it catches both defects it was written for — the `create_group` missing `userkey` and the
`/continue` 500 on an unowned correlation.

That test immediately earned itself twice over: the mutation run surfaced that
`_publish_started`/`maybe_complete` swallow `ValueError` from `TopicSet` into a log line,
so a caller forgetting a `userkey` produced a group that silently never announced itself —
the same shape as the `TypeError` already guarded there. Both guards now cover both.

**Deletion tombstones rather than freeing the id.** This is the one I said needed thought
rather than a quick answer, and the answer is that reuse is unsafe. Verified: `forget()`
used to `DELETE` the row, `exists()` is the `Minter`'s only uniqueness check, and
`sessionuuid = uuid5(NAMESPACE, correlationid)` is unsalted by design — so a reissued
`correlationid` derives *the same session uuid*, and a `claude` transcript left on a
runner's volume becomes resumable by the new correlation. That is one user's conversation
continuing inside somebody else's agent.

So `forget()`/`forget_tenant()` keep the row, set `deleted_utc` and clear `userkey` — the
id is reserved forever, and the tombstone discloses nothing about whose it was. The
alternative is guaranteeing every transcript keyed on that uuid is purged everywhere,
including volumes this process does not own, which is not a guarantee EventBridge can
make. Cost is one short row per deleted correlation. Migrated with an `ALTER TABLE`, the
same idiom `store.py` uses for `prompts.submitter`, and a test covers an index created
before the column existed.

The reviewer was also right that the docstring contradicted the code: it claimed "freeing
the id for reuse is deliberate" about a `DELETE` nobody had reasoned about.

Design doc: §6.1a records the tombstone decision and why, since the index is what
enforces it.

Tests: 855 passing (+13), ruff clean.

Still outstanding: the four phase documents (`IMPLEMENTATION_REPORT3.md`,
`README_PHASE3.md`, and the two owed Phase 2 documents).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath

mrsabath commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Round two is in 3278d75 — the four remaining code items. All 11 checks passing, 855
tests.

T22 now checks the direction that enforces the rule

You were right that it could not fail for its stated reason. CONFIGMAP_VARS ⊆ found
catches a stale entry and nothing else; found ⊆ CONFIGMAP_VARS ∪ SECRET_PATH_VARS is the
direction that catches a new variable classified as neither.

Scoped to Phase 3's variables, with the 36 that pre-date §8.3 listed explicitly — and
test_the_baseline_list_is_still_accurate keeps that list from silently widening the
exemption, which was my worry about hardcoding it at all.

I took the deliberate-failure suggestion literally: injecting an unclassified
e("EB_UNCLASSIFIED_X") read into config.py turns the test red with the variable named.
The hardcoded ten-space indentation is now \s*, per the precedent you pointed at in the
same file.

RequestsMirror and NtfyPublisher

Both take stores= now. Your read looks right that these are where the 77807b6 sweep
stopped rather than a decision — Consumer and GroupMirror got it and these did not,
with no reasoning anywhere for the difference. The mirror's case was the worse of the two:
back-filling into the shared store means a correlation the bridge did not originate shows
a blank prompt on its owner's page, which is the exact gap the mirror exists to close.
ntfy's group-existence check needed the same treatment, which I would have missed if I had
only fixed the two functions you named.

The integration test was the right call, and it paid for itself twice

Faking at KafkaProducer rather than Producer means the real handler, the real
Producer and the real TopicSet all run, and the assertion is the topic string. I
confirmed it catches both defects it was written for by reverting each fix in turn.

Then it found a third thing nobody had flagged. Mutating create_group back to its buggy
form produced this in the captured output:

[groups] could not publish group.started for loud-weasel-0357:
    ValueError("userkey is required to name the 'responses' topic in multi mode")

_publish_started and maybe_complete were swallowing ValueError from TopicSet into
a log line — so a caller forgetting a userkey produced a group that silently never
announced itself. Exactly the shape of the TypeError I had already guarded there after
the first round, and I had not thought to widen it. Both guards now cover both, and a
genuine broker failure is still handled.

Your framing — "one test per route that goes through Handlers with a real Producer
against a fake Kafka, asserting the topic string, is a better investment than more unit
tests on the pieces" — is the most useful single suggestion in the review.

forget(): tombstone, never free the id

This is the one I said I wanted to think about rather than answer quickly, and the thought
changed the answer. I verified the mechanism instead of reasoning about it:

after forget, exists(): False
sessionuuid before     : 9189c66c-9eff-5cb5-92b2-0565ca420c33
sessionuuid if reissued : 9189c66c-9eff-5cb5-92b2-0565ca420c33
SAME session uuid: True

So your reading was right and the consequence is worse than the docstring implied.
exists() is the Minter's only uniqueness check, sessionuuid is unsalted by design,
and therefore a reissued correlationid derives the same session uuid — a claude
transcript left on a runner's volume, or a checkpoint that outlived the delete, becomes
resumable by the new correlation. One user's conversation continuing inside somebody else's
agent.

forget() and forget_tenant() now keep the row, set deleted_utc and clear userkey:
the id is reserved forever and the tombstone discloses nothing about whose it was, which
matters on a deletion path. I chose that over your other option — purging every transcript
keyed on that uuid — because that is a guarantee EventBridge cannot make: the volumes are
not all its own. Cost is one short row per deleted correlation. Migrated with ALTER TABLE, the same idiom store.py uses for prompts.submitter, with a test covering an
index created before the column existed.

And you were right about the docstring: it asserted "freeing the id for reuse is
deliberate" about a DELETE nobody had reasoned about. §6.1a now records the decision and
why, since the index is what enforces it.


Next: IMPLEMENTATION_REPORT3.md

Taking this next, and your argument for doing it now rather than later is the one I found
most persuasive in the review — the findings are attributable to specific commits today
and will not be in a month. Following Phase 1's report as the model: measured numbers,
failures with root causes, and an explicit list of what is not verified.

What I plan to put in it, so you can redirect me before I write rather than after:

  • The findings, each with its commit and the symptom that hid it. The use-after-close
    eviction bug (49aed05) with the detail you singled out — first attempt pinned only the
    SSE path, fixed the visible case, left the class open, and the bug was in the eviction
    contract rather than one caller. The except Exception pair: a signature mismatch that
    silently stopped every group event from publishing, and a broken ownership index
    surfacing as "correlation ID space exhausted". Plus this round's ValueError variant,
    which is the same lesson a third time.
  • Your five must-fixes as findings in their own right, including the one that matters
    most for a report: a single-mode regression shipped behind a headline claiming the
    opposite, and the suite could not see it because the fixtures supplied by hand the
    arguments the production call sites omitted.
  • The deltas from the design as decisions, not drift — single-tenant store location
    chosen for upgrade safety, eviction by reference-drop, tombstoned deletion, the
    single-mode events/dead names.
  • What is not verified, which for this phase is a long and more useful list than the
    verified one: no cluster run, no two-tenant deployment, multi not functioning end to
    end until T6, and the per-tenant throughput ceiling §10 asks for still unmeasured.

On the two owed Phase 2 documents — I agree they are owed, and your diagnosis of why the
gap shows is hard to argue with: §6 of DESIGN_PHASE2.md is a test-results-and-findings
report filed inside a design document, and §7 sends people to README_PHASE1.md for a
phase that added the device flow, the approved-user list, the break-glass path, a 300 s
revocation window and the keyset rollout. I would rather do those as a separate PR than
grow this one further, unless you would prefer them here.

Asked for in the #883 review, and the argument for writing it now rather than later was
the most persuasive one there: the findings are attributable to specific commits today and
will not be in a month. 361 lines, following `IMPLEMENTATION_REPORT1.md`'s contract —
measured figures rather than estimates, failures with root causes, and an explicit account
of what is NOT verified.

Phase 0 and Phase 1 each ship three documents; Phase 2 shipped none, and the gap shows in
that there is nowhere to put the §6.1 split-consumer trap except inside a design document.
This starts closing that for Phase 3.

**Measured rather than asserted.** §6.1 estimates the per-tenant descriptor cost and
derives a ceiling; measured it is 6.0 descriptors per open store (10 tenants, 60 fds),
which puts ~680 tenants under this host's 4096 soft limit and ~170 under the 1024 the
design assumed — so squarely inside its "150-200" once the limit is held constant. The
ownership index costs 27.4 µs per `claim`, 1.7 µs per `exists`, and 270 ms to seed 10,000
ids. That last figure is the one that matters: it settles §2.6's objection to seeding,
because the *one-time* backfill it appeared to rule out costs 270 ms once, while the
*per-start* seeding it correctly rejects is what the index exists to avoid.

**Seven deltas from the design, recorded as decisions rather than drift** — the
single-tenant store location, eviction by reference-drop, tombstoned deletion, the
single-mode `events`/`dead` names, `agent` joining `SIGNED_ATTRS`, the exported userkey
validator, and prefix validation.

**The findings, ordered by what they cost to find.** Three are worth the document on their
own:

- The `single`-mode regression shipped behind a headline claiming it could not happen,
  with the reproduction (50 of 50 live ids reported free), the 1-in-25,000 arithmetic over
  a verified 50x50x10,000 id space, and the three reasons it is instructive: it was in the
  default configuration, the suite could not see it because no fixture simulated an
  upgrade, and §2.6's wording contributed.
- The eviction contract, where the lesson is the *first* fix: pinning only the SSE path
  addressed the case that was easy to picture and left the class open.
- `except Exception` converting three separate programming errors into log lines, on three
  separate occasions, which is why it is filed as one finding with the pattern extracted.

Also records the suite's structural blind spot as a finding in its own right — a fake
placed at the boundary under test cannot test that boundary — since that is what let two
defects survive 200-odd new test functions.

**§5 is the longest section, deliberately.** `multi` does not work end to end until T6 (no
response is consumed); no cluster, no two-tenant deployment, no Kafka; reads not
owner-scoped; Tier A is not isolation; the per-tenant throughput ceiling §10 asks for is
still unmeasured; the `claude --help` flag assertion does not run in CI; `seed_from` is
capped at 10,000 per store; and `is_valid_userkey` is shape validation, not authorisation.

Every figure in the report was measured on this branch, every cited commit verified to be
on it, and every cited design section verified to exist — §21.6 turned out to be
`DESIGN_PHASE1.md`'s and is now qualified as such. Two numbers in my own PR description
were wrong and are corrected here: the test delta is +232 unique functions (525 to 757),
not the 139 or 217 claimed earlier.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at c25a0c77, after 6ebdd80, 3278d75 and c25a0c7. All five must-fixes
from round 1 are genuinely fixed
— I re-read each against this head rather than taking
the comment thread on trust:

Round-1 must-fix State at c25a0c77
Index never seeded (single-mode regression) seed_from() + __main__.py:142 backfill ✔
create_group dropped userkey handlers.py:333 passes it; row and members colocate ✔
close/cancel unauthenticated cross-tenant mutation _authorize_mutation, 404 per §6.2 ✔
agent outside SIGNED_ATTRS signing.py:219, one canonicalisation break ✔
userkey → path join unvalidated tenancy.is_valid_userkey, invalid ⇒ shared/ + unattributed ✔

The four deferred suggestions landed too, and tests/test_topic_routing_e2e.py is the
right shape — faking at KafkaProducer so the real handler, Producer and TopicSet
all run, asserting the topic string. It earned its keep immediately by finding the
ValueError-swallowed _publish_started.

IMPLEMENTATION_REPORT3.md is a good report, and I checked its numbers rather than
reading them. They hold up (see Verification below).

What I found this round

Two must-fixes, same root cause, both reproduced against c25a0c77. 6ebdd80
introduced validation of the request's agent name — correctly, at the HTTP boundary —
but placed it after the writes in both submit paths. So a 400 now leaves committed
state behind:

  • create_group has already created the group row and published group.started.
    With an Idempotency-Key, every retry then returns 200 {created: false, members: []}
    — the group never gains a member, never completes, never notifies. That is the exact
    failure the userkey= comment at handlers.py:327 describes, reached by a route the
    fix did not close.
  • start_agent has already written the session and the prompt row and claimed the
    correlation id, with nothing published — a transcript showing a turn that was never
    submitted, which is §4.7's own description of the /continue 500.

The fix is to move both _agent_for calls above the first write. Worth noting
tests/test_review_fixes.py:347 already asserts exactly the right thing for /continue
(get_prompts(corr) == []); the new agent-name test 25 lines below it asserts only the
status and publish_request.assert_not_called().

On the design-vs-code gap (the other half of the ask)

I walked §2–§9 against the tree. The deltas are recorded honestly and §3 of the report
is accurate as far as it goes; three things are not in it:

  1. __main__.py's backfill is per-start, not one-time — it opens every tenant store
    and re-reads all_correlations on every boot. That is the N-store startup seeding
    §2.6 rejects, and §6.1a's "seeded once" plus the report's "once, on the first start
    after an upgrade" both describe something the code does not do. Measured below.
  2. AgentSpec.limits is parsed, validated by nothing, and enforced nowhere. Skill
    says plainly that it carries sha256 for T19; Limits makes no such disclaimer, so
    timeout_s = 900 in an agent.toml loads clean and does nothing.
  3. §8.3 was not updated with §2.6. It still says the signed set "changes twice" and
    names only userkey and depth, while §2.6, signing.py and ce.py now all say
    three — and signing.py:216 cites §8.3 as the authority for the rule it broke once
    instead of twice.

Everything else I checked lines up: EB_NTFY_TOPIC_SECRET_PATH's "Required in
multi" and EB_SUBSCRIBE_TIMEOUT_S are unenforced, but the new status header covers
them under T6/T11 explicitly, and §6.5's deletion API (forget_tenant,
correlations_for, is_tombstoned, userkeys) shipping without callers is T21
territory and reads as deliberate.

Verification I ran locally

Check Result
python -m pytest in eventing/ at c25a0c77 853 passed, 7 skipped (860 collected)
same at base 10980948 549 passed, 7 skipped (556 collected)
ruff check . / ruff format --check . @ 0.11.4 clean; 135 files already formatted
Report's "525 → 757 unique test functions (+232)" confirmed exactly (grep -c '^def test_' over tests/)
Report's "860 collected" confirmed
CI 11/11 green

I also applied all six code suggestions below to a scratch worktree and re-ran
everything: 853 passed, 7 skipped, ruff check and ruff format --check still
clean, and each one closes the behaviour it was written for. After the two reorderings
the create_group reproduction above becomes:

POST /v0/groups {"prompts":["a","b"],"agent":"../../etc"}  Idempotency-Key: retry-me
  -> 400 Bad Request
  topics published: []
  groups in alice's store: []
POST again, same key, with "agent":"triager"
  -> 202 Accepted {"created":true,"members":["even-firefly-4773","hopeful-heron-9632"]}

My 7 skips vs the report's 5: test_graceful_shutdown.py:88,101 skip here because this
sandbox denies subprocess spawn, so 855 passed, 5 skipped is consistent with a host
where they run. The 5 needs_kubectl skips match.

The PR body's Testing section is now stale — it still reports 840 passed,
+217 unique (525 → 742), 845 collected from 6ebdd80, and its T4 row omits agent;
the "656 tests that existed before this branch" line above it is the figure you already
retracted. IMPLEMENTATION_REPORT3.md has the right numbers.

REQUEST_CHANGES for the two orphan-write paths only. Nothing in the tenancy core moved
in the wrong direction this round — the structural fix (test_topic_routing_e2e.py) is
the most valuable thing in the diff, and the ordering bug it did not catch is a
_agent_for placement, not a tenancy defect.

Comment thread eventing/eventbridge/handlers.py Outdated
Comment thread eventing/eventbridge/handlers.py Outdated
Comment thread eventing/eventbridge/__main__.py
Comment thread eventing/eventrunner/agentspec.py Outdated
Comment thread eventing/eventrunner/agentspec.py
Comment thread eventing/eventbridge/registry.py Outdated
Comment thread eventing/tests/test_review_fixes.py
Comment thread eventing/agentdocs/DESIGN_PHASE3.md
Comment thread eventing/README.md
Comment thread eventing/agentdocs/IMPLEMENTATION_REPORT3.md Outdated
… round 2)

Both must-fixes reproduced against c25a0c7 before fixing, and both are my own round-1
fix introducing a new bug class: `6ebdd80` added validation of the request's `agent`
name at the HTTP boundary — right place, right reason — and placed it AFTER the writes.

**create_group** had already committed the group row and published `group.started` when
the 400 returned, and because the row is keyed on the Idempotency-Key the state was
permanent: every retry read back `created: false, members: []`, which looks like success.
That is verbatim the failure the `userkey=` comment added in the same round describes,
reached by a route that fix did not cover.

    POST /v0/groups {"prompts":["a","b"],"agent":"../../etc"}  Idempotency-Key: retry-me
      -> 400,  topics: ['kev1-st-alice-c8ff0431-responses'],  groups: ['wild-weasel-1995']
      retry, same key, valid name -> 200 {created: false, members: []}

**start_agent** had written the session row, the prompt row and claimed the correlation
id — a transcript showing a turn that was never submitted, plus an id nothing ever
un-claims, since tombstoning is for deletion rather than abandonment.

Both resolutions now happen before the first write. `_agent_for` needs only `caller` and
`body`, so there was never anything to gain by deferring it. After the fix both paths
publish nothing, write nothing, and the group retry launches the batch properly.

The reviewer's observation about the tests is the more useful half: the assertion that
would have caught both — `get_prompts(corr) == []`, *a refusal writes nothing* — already
existed 25 lines earlier in the same file, on a different route. The new tests assert it
on both paths, with the Idempotency-Key retry for the group case, and were verified by
reverting each ordering (7 failures).

### Three design/code gaps §3 of the report had missed

**The backfill was per-start, not one-time.** §6.1a says "seeded once" and the report
said "once, on the first start after an upgrade"; the loop re-opened every tenant store
and re-read `all_correlations` on every boot — exactly the N-store startup seeding §2.6
rejects, reintroduced by the fix for the missing seed. `INSERT OR IGNORE` kept it correct;
what repeated was the cost §2.6's argument is entirely about. A `seed_state(scope,
done_utc)` table now records each scope, and `needs_seeding` is checked before the store
is opened. Measured at 100 tenants x 200 correlations:

    before:  start 1  702 ms / 101 opens    start 2  210 ms / 101 opens
    after:   start 1  702 ms / 101 opens    start 2  0.8 ms /   1 open

Sound because nothing can add an unindexed id to a seeded store: every mint claims as it
mints.

**`AgentSpec.limits` was parsed and enforced nowhere, silently.** `Skill` states plainly
that it carries `sha256` for T19; `Limits` made no such disclaimer, so `timeout_s = 900`
loaded clean and did nothing. Now says so, with which field belongs to which later task.
Its three conversions also escaped the module's own `SpecError` contract —
`timeout_s = "900s"` raised a bare `ValueError` past `run_agent`'s handler instead of
becoming the `phase=error` event §5.1 promises.

**§8.3 disagreed with §2.6 and the code.** It still said the signed set "changes twice"
and named two attributes, while §2.6, `signing.py` and `ce.py` all said three — and
`signing.py` cited §8.3 as the authority for a rule §8.3 stated over the wrong count.

Two smaller ones from the same pass: a tombstoned correlation could be re-claimed by the
NULL owner (`row[0] == userkey` reads as "already mine" when both are NULL),
contradicting what `forget` guarantees — unreachable from `Minter.mint`, which checks
`exists()` first, but reachable as soon as a correlationid arrives from outside, which
§7's triggers will bring. And `registry.parse` accepted `agent` unchecked, the only field
it did not validate, in a function whose docstring promises every inconsistency is a
refusal; a typo there meant an asynchronous `phase=error` on every request from that user
rather than a startup refusal. Verified: the registry now refuses with the bad value named.

### Documentation

- The component README's Phase 3 block no longer orphans the "Two of these are
  load-bearing" sentence from its payoff 25 lines later — the block moved below it under
  its own `#### Phase 3` heading, and the note count is now right (three, not two). Added
  the T6 "does not work end to end" warning here too.
- `IMPLEMENTATION_REPORT3.md` §1 now counts from a named point (`3278d75`) instead of
  "currently", since a report cannot be right about the diff of the commit containing it;
  both figures are given. New §4.7 and §4.8 record this round's findings, including that
  fixing one orphan-write instance did not teach me to look for the class — the same
  lesson §4.2 records about the eviction contract, learned a second time.

Tests: 860 passing (+5), ruff clean, both multi-mode startup paths smoke-tested.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath

mrsabath commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Round three is in 258da7a — both orphan-write paths and all three design/code gaps. Pushed before this comment, so your review at c25a0c77 predates it.

Both must-fixes were my own round-1 fix introducing a new bug class. 6ebdd80 added agent-name validation at the HTTP boundary — right place, right reason — and put it after the writes. I reproduced each against c25a0c77 before touching anything:

POST /v0/groups {"prompts":["a","b"],"agent":"../../etc"}  Idempotency-Key: retry-me
  -> 400,  topics: ['kev1-st-alice-c8ff0431-responses'],  groups: ['wild-weasel-1995']
  retry, same key, valid name -> 200 {created: false, members: []}

Permanent, because the row is keyed on the Idempotency-Key — and it reads as success. Exactly the failure the userkey= comment I added in the same round describes, reached by a route that fix did not cover. start_agent was the quieter one: session row, prompt row and a claimed correlation id, nothing published, and nothing ever un-claims the id since tombstoning is for deletion rather than abandonment.

Both _agent_for calls now run before the first write, with a comment at each site saying why the ordering is load-bearing. Your observation about tests/test_review_fixes.py:347 was the useful part — the /continue test already asserted get_prompts(corr) == [] while the agent test 25 lines below asserted only status and publish_request.assert_not_called(). Asserting the absence of published output and the absence of committed state are different checks, and I had written the weaker one directly beneath the stronger one.

§4.8 of the report records that fixing one instance of an orphan write did not teach me to look for the class — the same lesson §4.2 records about the eviction contract, learned a second time.

The three gaps are all real and all fixed:

  • Per-start backfill. You were right that it is not one-time: it opened every tenant store and re-read all_correlations on every boot, which is the N-store startup seeding §2.6 rejects. Both §6.1a and the report described something the code did not do. Now gated per scope on a persisted seed_state row, checked before the store is opened — so an already-seeded tenant is never opened and never scanned, which is the cost §2.6 actually objects to. seed_from was always INSERT OR IGNORE, so repetition was harmless to correctness; what repeated was two SQLite connections and an indexed scan per tenant before the socket binds. Measured at ~210 ms per restart for 100 tenants × 200 correlations, and §2.6's own worked example (100 × 10,000) extrapolates to ~6 s added to every boot. Skipping is sound because nothing can add an unindexed id to a seeded store — every mint claims as it mints.
  • AgentSpec.limits was parsed, validated by nothing, enforced nowhere. Skill says plainly that its sha256 is for T19; Limits made no such disclaimer, so timeout_s = 900 loaded clean and did nothing. It says so now, per field. Its conversions also escaped the module's SpecError contract — timeout_s = "900s" raised a bare ValueError past run_agent's handler instead of becoming the phase=error event §5.1 promises.
  • §8.3 vs §2.6. Fixed, and the embarrassing part is that signing.py cited §8.3 as the authority for a rule §8.3 stated over the wrong count.

Two more from the same pass, both found by looking for the class rather than the instance: a tombstoned correlation could be re-claimed by the NULL owner (row[0] == userkey reads as "already mine" when both are NULL) — unreachable from Minter.mint, which checks exists() first, but reachable the moment a correlationid arrives from outside, which §7's triggers will bring. And registry.parse accepted agent unchecked, the only field it did not validate, in a function whose docstring promises every inconsistency is a refusal.

On the stale PR body — you were right, and it was stale in three places, not one. Now corrected: 859 passed / 6 skipped at 258da7a, +237 unique (525 → 762), 865 collected; the T4 row names all three signed attributes. I also removed the live "656" claim under The additive guarantee, which I had retracted in the Testing section while leaving it asserting upstairs — the real figure is 573 collected at the merge-base, and I measured it rather than deriving it. I have added a line saying IMPLEMENTATION_REPORT3.md is the figure of record, because a PR body cannot stay right about a branch that is still moving, and this is the third time I have corrected these numbers.

On your 7 skips vs my 6: test_graceful_shutdown.py spawns a real EventBridge, so it skips wherever subprocess spawn is denied and runs elsewhere. 5 (needs_kubectl only), 6 and 7 are all consistent runs of the same tree; the body now says so rather than quoting one host's number as the truth.

Dzięki — the reproduction you included is what made the Idempotency-Key interaction obvious, and the test_review_fixes.py:347 comparison is the kind of finding I would not have got to by re-reading my own diff. (Thanks.)

@mrsabath
mrsabath requested a review from aslom October 5, 2026 17:31

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified at 258da7a. All eleven round-2 findings are resolved, and I checked each
against the tree rather than against the replies — including mutation-reverting every fix
to see which ones the suite actually holds.

# Finding Fix Verified by
1 create_group 400 after the group row + group.started _agent_for moved above groups.create repro: nothing published, no row, retry returns 202 with both members
2 start_agent 400 after session/prompt/claim moved above mint_for repro: no topic, no session, index count 0
3 backfill per-start, not one-time seed_state(scope, done_utc) + needs_seeding gate measured 210 ms / 101 opens → 1.1 ms / 0 opens per restart
4 [limits] raised past SpecError conversions wrapped all three fields now raise SpecError
5 limits parsed, enforced nowhere, undisclosed Skill-style disclaimer docstring says "not yet enforced", names which task owns each field
6 tombstone re-claimable by the NULL owner explicit deleted_utc check Collision("… is tombstoned …") for both NULL and a tenant
7 registry.parse accepted agent unchecked validate_name at parse startup refusal names the bad label; blank still falls through
8 the missing "a refusal writes nothing" assertion added, plus a create_group test with the retry mutation: 6 and 1 failures respectively
9 §8.3 stale on the signed set "changes once, for three attributes" + agent + a note on what the earlier revision said read
10 README's orphaned "Two of these…" Phase 3 block moved below, own #### Phase 3 heading read; also fixed "Two notes" introducing three, and added the T6 warning where an operator will read it
11 report §1 counting its own commit "As of 3278d75" with both figure sets read

Three of these went further than asked, correctly. §3 of the fix for #3 skips the Store
construction as well as the scan, which is where the cost actually was — so the restart
penalty is gone rather than reduced. #2 moved above mint_for rather than merely above
the writes, which is the version that does not burn an id. And #10 surfaced two more
things the original insertion had broken.

IMPLEMENTATION_REPORT3.md §4.7 and §4.8 record this round, and §4.8 opens by admitting
that §3's conformance claim was asserted rather than checked. A report filing its own
over-claim as a finding is the right instinct.

Verification I ran

Check Result
python -m pytest in eventing/ @ 258da7a 858 passed, 7 skipped (865 collected)
ruff check . / ruff format --check . @ 0.11.4 clean, 135 files formatted
Independent repro of all 7 code findings 11/11 assert the fixed behaviour
Startup backfill, 100 tenants × 200 correlations 1.1 ms, 0 store opens on restart (was 210 ms / 101)
CI 11/11 green
Commits 12, all Signed-off-by, Assisted-By not Co-Authored-By
.claude/ / .vscode/ changes none

One thing worth doing before this grows further

Three of the six code fixes in 258da7a have no regression test anywhere. Reverting
agentspec's try/except, claim's tombstone check, or registry's validate_name
leaves all 858 tests passing. The three that do have tests are solidly held — 6, 1
and 4 failures under mutation. The commit adds exactly 5 test functions (757 → 762),
which accounts for the four seed_state tests and the one create_group test and
nothing else.

That is not a correctness problem — all six fixes are present and I verified each
behaviourally. It is that the standard this PR has applied since 6ebdd80 ("every fix
has a regression test, verified by reverting its fix") was applied to half of this
commit, and the three without one are the three whose absence is invisible.

Non-blocking, and noted rather than commented on: the limits fix took the try/except
without the timeout_s < 0 or max_events < 1 range check, so timeout_s = -5 still
loads clean. Defensible now that Limits is documented inert — flagging only so the
decision is on the record for whoever wires up the Popen wrapper.

Verdict

Approving. No blockers remain: both must-fixes are closed and reproduced closed, the
six suggestions are implemented, the three documentation gaps are corrected, and nothing
in the tenancy core moved in the wrong direction across three rounds. CI is green, the
author is a maintainer, there are no agent/IDE config changes, and EB_TENANCY_MODE
still defaults to single, which the pre-existing 525 test functions continue to pin.

Two things this approval is not, both of which the PR now states itself rather than
leaving me to say:

  • It is not approval to switch on multi. T6 means no response is consumed, T7/T8 mean
    reads and PUT /transcript are as open as Phase 2's, and §3.6 is clear that topics
    without ACLs are organisation rather than isolation. The README, the design header and
    the report all say so in those words now.
  • It is still a draft, so the merge call is yours. If you would rather land the test
    coverage for those three fixes here than in the T6 branch, that is the only thing I
    would hold it for — and I would not hold it long.

Comment on lines +126 to +162
def test_a_seeded_scope_is_not_seeded_again(tmp_path):
"""Round 2. §6.1a claims the backfill is one-time; the first implementation re-opened
every tenant store and re-scanned `all_correlations` on EVERY boot — the per-start
N-store seeding §2.6 rejects, reintroduced by the fix for the missing seed.

`needs_seeding` is what lets `__main__` skip the store open entirely. Measured effect
at 100 tenants x 200 correlations: 210 ms -> 0.8 ms per restart, 101 store opens -> 1.
"""
owners = OwnerIndex(tmp_path / "eventbridge")
assert owners.needs_seeding()
owners.seed_from(["brave-otter-0001"])
assert not owners.needs_seeding(), "the shared scope was not marked seeded"


def test_seed_scopes_are_tracked_independently(tmp_path):
"""A seeded tenant must not mark another, or a new tenant's existing ids would stay
invisible to the uniqueness check."""
owners = OwnerIndex(tmp_path / "eventbridge")
owners.seed_from(["brave-otter-0001"], UK)
assert not owners.needs_seeding(UK)
assert owners.needs_seeding(OTHER)
assert owners.needs_seeding() # the shared scope is its own


def test_an_empty_store_is_still_marked_seeded(tmp_path):
"""Otherwise it is re-opened and re-scanned on every boot forever."""
owners = OwnerIndex(tmp_path / "eventbridge")
assert owners.seed_from([]) == 0
assert not owners.needs_seeding()


def test_the_seed_mark_survives_a_restart(tmp_path):
root = tmp_path / "eventbridge"
one = OwnerIndex(root)
one.seed_from(["brave-otter-0001"], UK)
one.close()
assert not OwnerIndex(root).needs_seeding(UK)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] These four are the right tests and they hold — but three of the six code
fixes in this commit have none, and the suite does not notice when they are gone.

I mutation-reverted each fix in 258da7a independently and ran the whole suite, not
just this file:

Fix reverted Tests that fail
start_agent's _agent_for back after the writes 6
create_group's _agent_for back after groups.create 1
seed_from no longer marks the scope seeded 4
agentspec.parse's [limits] try/except removed 0 — 858 passed
OwnerIndex.claim's deleted_utc check removed 0 — 858 passed
registry.parse's validate_name(agent) removed 0 — 858 passed

The arithmetic agrees: this commit adds exactly 5 test functions (757 → 762), which
is the four above plus test_a_bad_agent_name_on_create_group_writes_nothing. Nothing
was written for the other three.

Not a correctness finding — I verified all six behaviourally and they work. It is that
the standard this PR set in 6ebdd80 ("every fix has a regression test, and I verified
each test is load-bearing by reverting its fix one at a time") was applied to half of
this commit, and the half without tests is the half whose absence cannot be seen. Two of
the three are also the ones whose reasoning is about a future caller — claim's
tombstone check is explicitly closed "where the invariant is written down" rather than
where it is currently reachable, and a guard with no test is exactly the guard a later
refactor drops while staying green.

Three short ones, in the same file, would close it:

def test_bad_limits_raise_SpecError_not_ValueError():
    """`run_agent` catches only `SpecError`, so a bare ValueError from these three
    conversions escapes the `phase=error` path §5.1 promises."""
    for field, value in (("timeout_s", '"900s"'),
                         ("max_output_bytes", '"4MB"'),
                         ("max_events", '"lots"')):
        with pytest.raises(agentspec.SpecError):
            agentspec.parse(f"[limits]\n{field} = {value}\n", name="triager")


def test_a_tombstoned_id_is_never_reclaimed(tmp_path):
    """`forget` clears `userkey` to NULL, so `claim(corr, None)` read as "already mine"
    and succeeded — contradicting the guarantee `forget` makes."""
    idx = OwnerIndex(tmp_path / "eventbridge")
    idx.claim("happy-otter-0001", None)
    idx.forget("happy-otter-0001")
    for owner in (None, UK):
        with pytest.raises(Collision, match="tombstoned"):
            idx.claim("happy-otter-0001", owner)
    idx.claim("lucky-finch-0002", None)      # a live id is still idempotent
    idx.claim("lucky-finch-0002", None)


def test_the_registry_refuses_a_bad_agent_name():
    """The only field `parse` accepted unchecked. Unvalidated, it reaches `ce_agent` and
    the runner rejects it — an async `phase=error` on every request from that user."""
    bad = json.dumps({"version": 1, "users": [
        {"issuer": "github", "userid": "mrsabath", "agent": "Not A Label"}]})
    with pytest.raises(registry.RegistryError, match="DNS-1123"):
        registry.parse(bad)
    ok = json.dumps({"version": 1, "users": [
        {"issuer": "github", "userid": "mrsabath", "agent": "triager"}]})
    assert registry.parse(ok).users[0].agent == "triager"

Two import notes, since I hit both: agentspec is not imported in this file at all, so
the first one needs from eventrunner import agentspec (locally or at the top).
Collision is not in the owner_index import line either, but the file already imports
it locally once — around test_a_duplicate_mint_is_refused — so the local form matches
what is there. The blank-agent case is worth a line in the third as well: agent: ""
must still fall through to ER_AGENT_NAME rather than being refused as an invalid label,
which the if agent: guard gets right and a future tightening could break.

I ran all three against 258da7a and against each fix reverted individually: each passes
at head and each fails against exactly its own revert, nothing else.

Comment on lines +67 to +71
855 passed, 5 skipped in 56s (Python 3.14.2 free-threaded, macOS arm64)
860 collected (the 5 skips are the difference)
```

**525 → 757 unique test functions** (+232); 860 collected with parametrisation. All five

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Stale by exactly the five tests this commit added — the same shape as §1, which
you fixed by naming a vantage point.

At 258da7a:

858 passed, 7 skipped   (865 collected)        <- 2 extra skips are my sandbox's
762 unique test functions (525 -> 762, +237)

against §2's 855 passed, 5 skipped, 860 collected and 525 → 757 (+232). The 5-test
delta is precisely test_a_seeded_scope_is_not_seeded_again,
test_seed_scopes_are_tracked_independently, test_an_empty_store_is_still_marked_seeded,
test_the_seed_mark_survives_a_restart and
test_a_bad_agent_name_on_create_group_writes_nothing.

§1's fix is the one to copy: name the commit the figures are from. A test-results block in
a document whose contract is "measured figures rather than estimates" will go stale on
every commit that adds a test, and dating it is cheaper than re-measuring it each time.

Worth keeping §2's own observation while you are there — the claude --help skip that
fires in CI but not locally is still true, and still the most useful line in the section.

…es (#883)

From the #883 approval: reverting `agentspec`'s `try/except`, `claim`'s tombstone check
or `registry`'s `validate_name` left all 858 tests passing, so three of the six fixes in
`258da7a` were invisible to the suite. The standard this PR has applied since `6ebdd80`
is "every fix has a regression test, verified by reverting its fix", and it had been
applied to half that commit — and, as the review put it, the three without one are the
three whose absence is invisible.

Fourteen tests, each verified by reverting its fix independently:

| Fix | Failures when reverted |
|---|---|
| `[limits]` conversions wrapped in `SpecError` | 4 |
| tombstone never re-claimable | 1 |
| `registry.parse` validates `agent` | 5 |

The tombstone test covers the case that was actually wrong — `claim(corr, None)` against
a tombstoned row, where `row[0] == userkey` is `None == None` and read as "already
mine" — and also asserts a *tenant* re-claim now fails with "tombstoned" rather than
"already owned by (single-tenant)", so the message points at the real reason.

The registry tests cover the shapes `validate_name` rejects and, separately, that a
valid name loads and an absent one stays empty so `ER_AGENT_NAME` still applies. A gate
that rejects what it should allow is the other way to get this wrong.

**Also closes the range-check gap the review flagged as on-the-record.** `timeout_s = -5`
and `max_events = 0` loaded clean:

    timeout_s = -5.0 | max_events = 0

Defensible while `Limits` is documented inert, but a negative deadline is nonsensical
input whether or not anything reads it yet, the module's own standard is that a policy
which cannot be understood fails to load, and the validation was already right there.
Rejecting now also means whoever wires up the `Popen` wrapper inherits a value they can
trust instead of re-validating. `0` keeps its documented meaning for `timeout_s` and
`max_output_bytes` ("no deadline" / "unbounded"), with a test pinning that the range
check does not reject the sentinels; `max_events = 0` is refused because a run that may
emit no events cannot report its own result.

Tests: 875 passing (+14), ruff clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
mrsabath added a commit that referenced this pull request Oct 5, 2026
…d three more

Addresses the #884 review. All findings reproduced against `21d49c9` before fixing, and
each fix verified by reverting it.

**The must-fix: `ensure_signing_material` validated everything except the one thing that
mattered.** It checked the directory, that `agents.json` parses, that it approves a kid,
that the seed file exists, and that the kid is *in* the keyset — never that the seed
**derives** the public key the keyset holds for it. Reproduced:

    kid in keyset           : True
    seed file exists        : True
    keyset parses           : True
    pub(seed)               : 29acbae141bccaf0…
    keyset's pub for eb-01  : 03a107bff3ce10be…
    MATCH                   : False

A divergent pair passed every check and produced exactly the cluster the function exists
to prevent: pods start (both volumes are `optional: true`), enforcement is on, the
service signs with a key nobody approved, and every event is refused with nothing local
to look at. Now refused before anything is applied, with both fingerprints and the two
remedies named. A corrupt seed is a named check failure rather than a traceback.

**`write_seeds` is all-or-nothing.** It wrote kid-by-kid and raised mid-loop, while
`agents.json` is rewritten by the caller afterwards — so if the FIRST kid in sorted order
was missing and a later one present, the first got a brand-new seed and the keyset kept
the old public key for it. That is the one state the module docstring promises cannot
happen ("the keyset cannot disagree with the keys it is supposed to authorize"), and it
is reachable from a partial rotation or a restored backup. The pre-check now runs over
the whole set before any file is touched, and the refusal says "Nothing was written".

Both of these are the same hazard from opposite ends, which is why the deploy-time check
matters even with the generator fixed: the likeliest real case is neither, but an
`agents.json` copied from a teammate while the seeds on disk are local.

**The seed paths move out of the ConfigMap and into each Deployment's `env`.** The
overlay quoted `configmap.yaml:54-55` — "they name Secret mounts and belong in the
Deployment beside the volume" — immediately before doing the other thing. No leak, since
the value is a path, but a rule the repo states and the patch cites should not be a rule
the patch breaks. Followed the rule rather than rewriting it, which also keeps
`test_manifests.py`'s Secret-vs-ConfigMap assertions aligned and leaves #883 free to
extend its `SECRET_PATH_VARS` tuple to these two variables, as §8.3 implies it should.

`env` does not exist on either base container (only `envFrom`), so the patch adds the
whole list; an `env/-` append would fail at render time. All four overlays still build,
`kind` stays fully unsigned, and `kind-signed` renders both paths on the Deployments with
enforcement still in the ConfigMap where the §14.1 digest covers it.

**`test_the_test_overlay_mounts_no_credential_so_mock_mode_is_automatic` passed for a
narrower reason than it documented.** Its docstring says "no Secret is referenced", and
the overlay now renders `secretName: eventrunner-signing-key` — only the literal
`secretRef` spelling kept it green. It now checks Secret references by name against an
allowlist of the two signing Secrets and rejects anything `anthropic`-shaped by either
spelling. Verified by injecting a credential Secret into the test overlay: the test goes
red, where before it would not have.

**§12 documented 12 preflight checks; the script has 13.** `check_claude_cli_image` was
added with the demo overlay and never got a table row, while
`IMPLEMENTATION_REPORT1.md` and `README_PHASE1.md` both reference checks by number. Added
row 13 with its reasoning (it runs on a cluster node because the CLI's bundled `bun`
aborts under QEMU user-mode emulation), and corrected "checks 1-8, 10-12" to "10-13" in
§13 step 0 and both places `k8s_deploy.py` repeats it. Pre-existing drift, folded in here
because check 14 is on this branch's own list.

Tests: 575 passing (+7), ruff clean, all four overlays build.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
mrsabath added a commit that referenced this pull request Oct 5, 2026
…and how to check them (#890)

* docs(eventing): record the claims a docs review disproved, and how to check them

The user-facing eventing pages (rossoctl/rossoctl#2609) restated every claim in
DESIGN_PHASE2 for a reader who cannot see the code. Six review rounds then ran
each one against eventing/ at d9677dd. Thirteen did not hold.

DESIGN_PHASE2 §8 records them, rather than editing §§2.6, 3, 4.3 and 4.4 in
place, so the reasoning stays readable as what was intended and §8 says what the
code does. The four §4.4 promises that fail: audit mode logs nothing, a pinned
group event is flagged and then applied anyway, a terminal signature stops
proving who finished a run once INSERT OR REPLACE lets an unsigned frame
overwrite it, and nothing checks that EB_SIGNING_KID is in the keyset. A fifth,
promised nowhere and worse: EB_REQUIRE_RESPONSE_SIGNATURE with no keyset refuses
nothing, silently, while the request side with no key refuses everything.

CLAIMS.md turns that into five questions to ask of a control claim before it
ships — which code path, what default, what happens half-configured, can an
attacker choose the input the check reads, what does it become one release from
now — plus how to run the check cheaply and what a correction should preserve.

None of the thirteen was careless about mechanism; §4.4 is unusually precise.
Each was written from the change that introduced the control and stayed true for
the configuration that change was exercised in. Every one coexisted with a
passing test suite.

Tracked as #885, #886, #887 and #888; #889 came out of the same review without
being a claim in any document.

Docs only. No code changes, so this does not overlap #884, and it touches
agentdocs/README.md where #883 also does — whichever merges second resolves one
table row and one reading-order entry.

Assisted-By: Claude Code
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

* docs(eventing): fold CLAIMS.md into DESIGN_PHASE2.md §8, per review

@aslom on #890: "please merge CLAIMS into DESIGN_PHASE2.md or DESIGN_PHASE3.md as I
think the claims are part of design and they should reflect the phase of the design
they are related to."

Taken. `CLAIMS.md` is gone and its content is now §8.5–§8.8 of `DESIGN_PHASE2.md`,
directly after the thirteen findings it was derived from. Phase 2 is the right home: the
findings are claims *this* document made, and §8.4 already handed off to the procedure,
so the two halves were a section boundary apart for no reason.

The content moved rather than being rewritten — 133 lines out, 135 in. Headings demote
one level (the five questions become §8.5.1–§8.5.5) and the three references that
assumed a separate file are re-pointed: "see its §8 for the list" is now "see above",
and "`DESIGN_PHASE2.md` §8 is that shape" is now "§8.1–§8.3 above are that shape".

**On the phase question, which is the substance of the request.** The findings are
Phase 2's and the procedure is not, so §8 now opens by saying which is which:

> §8.1–§8.4 are about **Phase 2** specifically: thirteen claims *this document* made
> about the identity and signing controls, which do not hold in the code. §8.5–§8.8 are
> the general procedure that came out of them, and apply to a claim made in any phase —
> Phase 3's design makes claims of the same shape about tenancy and the signed attribute
> set, and nothing here is Phase 2 only.

That answers the question the PR body asked out loud ("is `CLAIMS.md` the right home? It
is process guidance sitting among design and build records") rather than leaving the
ambiguity in place. §8.5's own lead-in says the same thing for a reader who arrives
there directly, and gives the reason it is filed here rather than at repo root: a
procedure kept away from the findings that motivated it is a procedure nobody reads.

`agentdocs/README.md`: the standalone index row is gone, the Phase 2 row now mentions
both halves of §8, and the reading-order entry reads "**Writing a claim about a control,
in any phase?**" pointing at §8.5 for the questions and §8.1–§8.3 for the findings —
with "The findings are Phase 2's; the questions are not" stated explicitly, since that
distinction is the whole point of where this now lives.

Verified every `§N.N` reference in the merged document resolves to a heading that
exists, and that no `CLAIMS` reference survives anywhere.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

---------

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
mrsabath added a commit that referenced this pull request Oct 5, 2026
…884)

* feat(eventing): make signing mountable in a cluster, and generate the keys

#879 wired signing in both directions but left it unreachable outside a laptop:
the ConfigMap shipped `*_VERIFY_KEYSET_PATH` and `*_SIGNING_KID` as empty strings
and said seed paths "belong in the Deployment beside the volume" — and no
Deployment had one. The only volume in either was `data: emptyDir`, so there was
no way to get a key into a pod. That blocks S6/S8/S9, each of which is defined as
"reproducible on a local Kind cluster".

* `scripts/gen_signing_keys.py` — no key generation existed anywhere, so enabling
  signing meant hand-rolling `os.urandom(32).hex()` and deriving public keys in a
  REPL. Seeds are written 0600 under a 0700 directory and **never printed**; only
  kids and public keys go to stdout, so a pasted terminal transcript cannot leak a
  private key. Public keys are derived from the seeds rather than typed, so the
  keyset cannot disagree with the keys it authorizes. Refuses to overwrite an
  existing seed without `--force`, since that key may already be deployed and
  approved.

* Both Deployments mount a seed Secret at `/etc/ce-signing` and the keyset
  ConfigMap at `/etc/ce-keyset`, both `optional: true` — without that, every
  signing-less overlay would block in ContainerCreating waiting for a Secret that
  will never exist. No `defaultMode`: 0400 would be root-only and there is no
  `runAsUser`/`fsGroup` (OpenShift assigns the UID), so the default 0644 is what
  makes the file readable.

* `k8s/base/keyset-configmap.yaml` ships **empty** and a test will keep it that
  way. "There is never a key in this file" is checkable; "only public keys in this
  file" is not, and a 32-byte seed is indistinguishable from a public key by length.

* `k8s/overlays/kind-signed/` flips enforcement on. A separate overlay rather than
  patching `kind`, because S3/S5 are explicitly unsigned milestones and the
  before/after contrast is the demo — and because the `test` overlay's
  "mounts no credential" assertion should keep meaning something.

**A per-service Secret, not one shared.** Caught while rendering the overlay: both
Deployments initially mounted the same `event-signing-key`, which would have given
the two services one seed. That collapses the asymmetry the design rests on —
EventBridge would hold the key it verifies runner responses with and could forge
them, which is the exact property that made HMAC unacceptable (DESIGN_PHASE2.md
§4.3). Now `eventbridge-signing-key` and `eventrunner-signing-key`. This deviates
from the single `event-signing-key` name in README_PHASE1.md:704-722; that section
is rewritten in a later commit, as it also still describes a symmetric setup.

Verified: `data` remains volume index 0 in all three existing overlays, so the demo
overlay's `/spec/template/spec/volumes/0` PVC patch still targets the right volume;
no overlay renders a `kind: Secret`; `kind` stays fully off while `kind-signed`
renders enforcement plus both key paths; the rendered env maps onto the real
config fields; and keys from the generator verify through `verify_with_keyset`.

551 passed, 5 skipped — including the three manifest tests this could have broken
(`test_no_credential_is_ever_rendered_into_a_manifest`,
`test_the_test_overlay_mounts_no_credential_so_mock_mode_is_automatic`,
`test_no_manifest_sets_run_as_user`). ruff check and format clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

* feat(eventing): apply signing key material at deploy time, and purge it on teardown

`k8s_deploy.py --signing-keys DIR` applies one seed Secret per service plus the
approved-key ConfigMap, modelled on `ensure_ntfy_secret`: via stdin so the seed
never reaches `ps` through argv, and a no-op when the flag is absent so a redeploy
cannot silently switch enforcement off and strand a signed topic.

It validates before applying, because every one of these mistakes produces a
cluster that refuses all traffic with no local symptom — the service starts, signs
under a kid nobody approved, and the other side rejects everything:

* the key directory and each named seed file must exist,
* `agents.json` must parse as a keyset and approve at least one kid,
* the kid each service will sign under must be IN that keyset.

Each failure names the file and the command that fixes it.

Seeds are logged as a length only — not even a prefix. 4 hex chars of a 32-byte
seed is 16 bits of a private key, and unlike an API token there is nothing to gain
from identifying it. The keyset gets the opposite treatment on purpose: kids are
logged in full, because "the operator deployed yesterday's key set" is otherwise
invisible until it surfaces as an unexplained rejection.

`--overlay kind-signed` without `--signing-keys` now aborts unless both Secrets are
already deployed. Both volumes are `optional: true`, so without this the pods would
start happily with enforcement on and no key, and refuse every event.

Teardown: `--purge` now deletes both signing Secrets and the keyset ConfigMap.
It also deletes `eventbridge-ntfy`, which was listed in neither the `doomed`
preview nor the delete tuple — so a "purged" namespace kept a capability that lets
anyone who learns it read and publish your notifications. Leaving private keys
behind is worse: a later deploy silently inherits an identity the operator thought
they had destroyed.

17 new tests covering generation and application, including that the two services
are never given the same seed (a shared one would let EventBridge forge any
agent's response — the HMAC property §4.3 rejected), that seeds land 0600 under a
0700 directory, that the CLI prints public keys but never a seed on either stream,
and that each malformed-key-directory case is refused before it reaches a cluster.

568 passed, 5 skipped. ruff check and format clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

* fix(eventing): Phase 2 — verify the seed derives the approved key, and three more

Addresses the #884 review. All findings reproduced against `21d49c9` before fixing, and
each fix verified by reverting it.

**The must-fix: `ensure_signing_material` validated everything except the one thing that
mattered.** It checked the directory, that `agents.json` parses, that it approves a kid,
that the seed file exists, and that the kid is *in* the keyset — never that the seed
**derives** the public key the keyset holds for it. Reproduced:

    kid in keyset           : True
    seed file exists        : True
    keyset parses           : True
    pub(seed)               : 29acbae141bccaf0…
    keyset's pub for eb-01  : 03a107bff3ce10be…
    MATCH                   : False

A divergent pair passed every check and produced exactly the cluster the function exists
to prevent: pods start (both volumes are `optional: true`), enforcement is on, the
service signs with a key nobody approved, and every event is refused with nothing local
to look at. Now refused before anything is applied, with both fingerprints and the two
remedies named. A corrupt seed is a named check failure rather than a traceback.

**`write_seeds` is all-or-nothing.** It wrote kid-by-kid and raised mid-loop, while
`agents.json` is rewritten by the caller afterwards — so if the FIRST kid in sorted order
was missing and a later one present, the first got a brand-new seed and the keyset kept
the old public key for it. That is the one state the module docstring promises cannot
happen ("the keyset cannot disagree with the keys it is supposed to authorize"), and it
is reachable from a partial rotation or a restored backup. The pre-check now runs over
the whole set before any file is touched, and the refusal says "Nothing was written".

Both of these are the same hazard from opposite ends, which is why the deploy-time check
matters even with the generator fixed: the likeliest real case is neither, but an
`agents.json` copied from a teammate while the seeds on disk are local.

**The seed paths move out of the ConfigMap and into each Deployment's `env`.** The
overlay quoted `configmap.yaml:54-55` — "they name Secret mounts and belong in the
Deployment beside the volume" — immediately before doing the other thing. No leak, since
the value is a path, but a rule the repo states and the patch cites should not be a rule
the patch breaks. Followed the rule rather than rewriting it, which also keeps
`test_manifests.py`'s Secret-vs-ConfigMap assertions aligned and leaves #883 free to
extend its `SECRET_PATH_VARS` tuple to these two variables, as §8.3 implies it should.

`env` does not exist on either base container (only `envFrom`), so the patch adds the
whole list; an `env/-` append would fail at render time. All four overlays still build,
`kind` stays fully unsigned, and `kind-signed` renders both paths on the Deployments with
enforcement still in the ConfigMap where the §14.1 digest covers it.

**`test_the_test_overlay_mounts_no_credential_so_mock_mode_is_automatic` passed for a
narrower reason than it documented.** Its docstring says "no Secret is referenced", and
the overlay now renders `secretName: eventrunner-signing-key` — only the literal
`secretRef` spelling kept it green. It now checks Secret references by name against an
allowlist of the two signing Secrets and rejects anything `anthropic`-shaped by either
spelling. Verified by injecting a credential Secret into the test overlay: the test goes
red, where before it would not have.

**§12 documented 12 preflight checks; the script has 13.** `check_claude_cli_image` was
added with the demo overlay and never got a table row, while
`IMPLEMENTATION_REPORT1.md` and `README_PHASE1.md` both reference checks by number. Added
row 13 with its reasoning (it runs on a cluster node because the CLI's bundled `bun`
aborts under QEMU user-mode emulation), and corrected "checks 1-8, 10-12" to "10-13" in
§13 step 0 and both places `k8s_deploy.py` repeats it. Pre-existing drift, folded in here
because check 14 is on this branch's own list.

Tests: 575 passing (+7), ruff clean, all four overlays build.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

---------

Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath
mrsabath marked this pull request as ready for review October 5, 2026 21:40
@mrsabath
mrsabath requested a review from a team as a code owner October 5, 2026 21:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

3 participants