Repository navigation
fix(eventing): a rejected response must not act, only be recorded - #955
Conversation
#885. With EB_REQUIRE_RESPONSE_SIGNATURE=true a response that fails verification was rewritten to phase="error" — and then acted on anyway. Enforcement changed what was recorded, not what took effect, so the sentence the demo rests on ("a forged response is refused") was false in the configuration that asks for it. Reproduced against the real functions, enforcement ON, a 2-agent batch: before: batch completed_utc set with 1 of 2 agents terminal counts {finished: 0, failed: 1} <- the agent had succeeded [groups] duplicate terminal event ... ignored <- the real answer, dropped after: batch still open, transcript holds the genuine answer, rejected=2 and both forgeries on record The forgery did not merely add a lie; the duplicate guard then discarded the truth. Three paths, one rule: a rejected envelope is evidence, not a fact — it is stored and rendered, but reaches neither on_group_event, on_member_event, nor a sequence a non-rejected row already holds. The marker is a top-level `ce.KEY_REJECTED`, not `phase` or `data`: * `phase == "error"` is a value GENUINE failures carry (runner.py emits it when an agent exits non-zero), and mark_member_finished maps it to `failed`. A guard keyed on it would stop every real failure from finishing its member and hang the batch to its deadline — silence, which §21.6 calls the worst outcome. * `data` arrives off the wire intact, so keying on it is §8.5.4 answered wrong. The `__` prefix turned out not to be enough on its own: a header named `ce___rejected` strips to exactly that key, so the marker WAS forgeable until from_kafka_binary started refusing `__`-prefixed names — and a forger able to set it could mark every genuine answer rejected, inverting the control rather than bypassing it. Found by the test written to assert the opposite. The store conflict rule is asymmetric on purpose. A genuine event keeps INSERT OR REPLACE, because at-least-once redelivery and emit.seed_seq's cold-start renumbering both depend on it — making the insert non-clobbering in general would re-open the Phase 1 per-pod sequence bug under a new name. Only a rejected event is refused a row it did not already own, and the refusal is counted (rejected_not_stored) and logged, because a silent drop is what §4.4 forbids. The group mirror is a second, independent acting path: a forged group.completed refused live was applied unconditionally on the next restart. It now verifies group.completed — and only that, and only when a keyset and bridge kid are both set. Verifying everything would be catastrophic rather than strict: the mirror replays history that predates signing, runs on every start, and the test/kind overlays keep /data on an emptyDir, so refusing unsigned records would lose every group's state on every restart. Unconditional, no new flag: `not ok` is only reachable when a keyset is loaded and enforcement is on, so the rule inherits those flags. Audit mode carries no marker and keeps acting, which is what keeps the reject-rate rollout working. test_an_approved_runner_cannot_forge_a_group_event asserted "rather than settling the group" while wiring a list's append as the callback, so nothing in it could tell whether the group settled — §8.5.1 exactly. It now drives a real GroupService and fails without the fix. 947 tests pass (924 before). final_seen still counts a rejected terminal; it is presentational and left to a follow-up with a VERIFY comment. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
aslom
left a comment
There was a problem hiding this comment.
The core of this is right, and the hard part is right for the right reason. Keying the marker on a top-level __rejected rather than on phase or data is correct, and the two rejected alternatives are rejected on accurate grounds — runner.py does emit phase="error", final=True for a genuine failure and mark_member_finished does map it to failed, so a phase-keyed guard really would hang every batch containing a failed agent. Finding that ce___rejected strips to __rejected and closing it in the codec, then recording that the prefix argument was wrong, is §8.5.4 working as designed.
I ran the store's new conflict rule against sqlite directly rather than reading it, since all of item 3 rests on rowcount:
rejected into an empty slot rowcount=1 stored
rejected over a rejected row rowcount=1 replaced (idempotent redelivery)
genuine over a rejected row rowcount=1 replaced
rejected over a GENUINE row rowcount=0 refused, genuine row intact
genuine redelivery rowcount=1 collapses onto one row
All four quadrants behave as the docstring says, INSERT OR REPLACE is preserved on the path seed_seq depends on, and returning False rather than raising keeps the consume thread alive. I also checked insert_response is called from exactly one place, so there is no HTTP path — including the unauthenticated PUT /transcript — that can reach it with a forged marker.
The half-configured case is genuinely closed, not just asserted: keyset.load_if_set returns None when unset, so keyset=ks is None and the mirror's self._keyset is not None gate holds; and a keyset without EB_SIGNING_KID already SystemExits at startup, so the mirror cannot be reached in the state that would refuse every replayed completion. Test coverage is 23 new tests that line up one-to-one with the claims, and test_audit_mode_still_lets_an_unverified_event_act pins the thing that actually matters about the guard.
Approving. The enforcement logic is correct where it acts, the default path is unchanged, and
neither note below is a defect in this change — the first is a residual the fix does not reach
and the second is comment-only. Both are worth a follow-up rather than another round here.
The mirror still re-applies forged member terminals, and that is the same damage the BEFORE table shows. The PR correctly identifies the mirror as a second acting path and fixes group.completed. But on_member_event(replay=True) runs add_group_member and mark_member_finished before its if replay: return, and no marker exists on the wire, so on every start a forged terminal earlier in the topic than the genuine one wins the finished_utc IS NULL race again. On the test/kind overlays — the emptyDir case the mirror exists for — the replay is authoritative, so counts goes back to failed: 1 for an agent that succeeded. The scoping decision looks right to me; the justification for it ("group.completed is the only mirror path that can settle a batch") answers settling but not member status, and that gap deserves naming the way final_seen was named.
Three new comments state the opposite of what response_decision returns. They say an unsigned non-terminal and a no-keyset deployment "both report verified=False"; both return verified=True, with inline comments in signing.py explaining why. No behaviour is affected — the guards read ok — but the stated reason for reading ok describes a failure mode that cannot occur, and the real reason is the one test_audit_mode_still_lets_an_unverified_event_act already pins.
Smaller: to_kafka_binary has no __ filter to match the inbound one. Nothing re-serializes a marked envelope today, so this is §8.5.5 rather than a live hole.
On the scoping of final_seen — the VERIFY comment that names the exact edit and then says which sentence not to delete is the best version of §8.7 I have seen in this repo. That is the pattern the mirror note above should follow.
I could not re-run the suite (no venv and kafka is absent in my environment), so the 947/7/1 figures rest on the eventing-test check, which passed along with the other eleven.
All three are in comments rather than in the enforcement logic, which is why the suite is unchanged apart from one new test. `verified` was described backwards in three places. `response_decision` returns `verified=True` for an unsigned non-terminal frame and for a deployment with no keyset — acceptance BY POLICY rather than a check that passed, which is what keeps `accept and not verified` meaning exactly the audit-mode population. The comments said `verified=False`. The code was right (it keys on `ok`); the comments would have sent the next reader looking for a flag that does not behave that way, and the correct argument for reading `ok` is stronger: keying on `verified` would spare nothing extra AND would make audit mode inert. The mirror paragraph answered settling and read as if it covered membership. `on_member_event(replay=True)` runs `add_group_member` and `mark_member_finished` before its `replay` check, and the marker is local rather than on the wire — so a forged terminal earlier in the topic than the genuine answer wins `finished_utc IS NULL` on every start and the batch reads back `failed: 1` for an agent that succeeded. Bounded (the batch stays open) but worst on the test/kind overlays, where /data is an emptyDir and the replay IS the state. Not fixable by widening this check: verifying member events on replay needs require=True, which refuses all pre-signing history, and "verify only if signed" is not a control because a forger omits it. Scoped with a VERIFY naming the store-level fix, on the `final_seen` model. `to_kafka_binary` had no counterpart to `from_kafka_binary`'s refusal of `__`-prefixed names. Unreachable through today's callers — the marker is set on a plain dict after `envelope_dict` — but "unreachable" is a property of the current callers, not of the codec, and the inbound filter is what the control rests on. Filtered on both sides now, with a test. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
|
All three addressed in a3882e4. Thanks for running the four conflict quadrants against sqlite directly rather than reading the docstring —
Two things worth saying plainly: The The member-replay residual is real and I had not seen it. I had checked that Happy to file the store-level follow-up now, or fold it into M2 (#896) — the forged-event demo stage is going to want a restart beat anyway, which is exactly where this would show. |
|
Closing the one loop your review left open: you noted you could not re-run the suite, so the figures rested on the 924 on Everything else in the review was already in
Nothing further outstanding from my side — approved, green, and mergeable whenever you want it in. |
|
Merged as
|
Closes #885. M1 of the KubeCon MVP (
KUBECON_NA_2026.md§5) — #888 and #889 landed in #902/#901, so this is the remainder and the last thing blocking #896 (M2).Phase / scope
not okis only reachable with a keyset loaded andEB_REQUIRE_RESPONSE_SIGNATURE=true, so the rule inherits those flags.The bug, run rather than described
§8.6's rule. Same scenario both sides — a 2-agent batch, enforcement on, a forged terminal for member 1 and a forged
group.completed, then the genuine signed answer:Note the third log line in BEFORE. The forgery won
mark_member_finished'sfinished_utc IS NULLrace, so the genuine answer arriving afterwards was discarded as a duplicate — the forgery did not merely add a lie, it destroyed the truth, and the batch reported a failure for an agent that had succeeded.One rule, three paths
ce.is_rejectedis the single predicate; it is read by the consumer, byGroupService.on_member_event/on_group_event, and byinsert_response.Why the marker is a top-level key and not
phaseordata— both obvious alternatives are wrong:phase == "error"is a value genuine failures carry.runner.pyemitsphase="error", final=Truewhen an agent exits non-zero, andmark_member_finishedmaps it tofailed. A guard keyed onphasewould stop every real agent failure from finishing its member, hanging the batch to its deadline — failing as silence, which §21.6 names the worst outcome.test_a_genuine_error_phase_terminal_still_fails_its_memberwas written first and passes before and after.dataarrives off the wire intact, so keying ondata["signature_rejected"]is §8.5.4 answered the wrong way.A correction worth flagging: the
__prefix was not sufficient on its own. A header namedce___rejectedstrips to exactly that key, so the marker was forgeable untilfrom_kafka_binarystarted refusing__-prefixed names. The impact is the inverse of a bypass — a forger able to set it could mark every genuine answer rejected. Found by the test written to assert the opposite, which is why §8.5.4 asks the question instead of trusting the argument.The store rule is asymmetric on purpose
INSERT OR REPLACEon(correlationid, sequence)is load-bearing: at-least-once redelivery must collapse onto one row, andemit.seed_seqresumes numbering from the stored maximum on a cold/continueturn — the fix for Phase 1's per-pod sequence restart that stored six rows for a three-turn conversation. Making the insert non-clobbering in general would re-open that bug under a new name.So only a rejected event is refused a row it did not already own. It may create a row, and may replace another rejection (idempotent redelivery), never a row that is not marked rejected.
test_a_verified_answer_can_still_replace_a_redelivered_duplicateandtest_a_duplicate_delivery_still_collapses_onto_one_rowguard the unchanged side. The refusal is counted (rejected_not_stored) and logged, because a silent drop is what §4.4 forbids.The group mirror, scoped honestly
The mirror is a second, independent acting path that did no verification, so the consumer-side guard closed the hole only until the pod bounced: a forged
group.completedsitting on the topic was applied unconditionally on the next restart.It now verifies
group.completed— and only that, and only when a keyset and bridge kid are both configured. Verifying everything would be catastrophic rather than strict: the mirror exists to replay history that predates signing, it runs on every start, and thetest/kindoverlays keep/dataon anemptyDir, so refusing unsigned records would lose every group's state on every restart — the exact failure the module was written to prevent.group.completedis also the only mirror path that can settle a batch;on_member_event(replay=True)returns beforemaybe_complete.To be explicit, since claiming otherwise would be §8.5.1 again: the
GroupServiceguard protects the mirror against nothing on its own. The marker is written into SQLite by the live consumer and is not on the wire. The mirror's safety comes from its ownbridge_kidcheck.A test that claimed an outcome and checked a verdict
test_an_approved_runner_cannot_forge_a_group_eventassertedgroups[0]["phase"] == "error"with the message "rather than settling the group" — while wiring a list'sappendas the callback, so nothing in it could tell whether the group settled. §8.5.1 exactly: a claim about an outcome has to follow the path to that outcome. It keeps its name, now drives a realGroupService, and fails without the fix (verified by reverting the guards).Checks
ruff checkclean.rejectedcolumn's idempotentALTERagainst a database created without it, mirroring theprompts.submitterprecedent.Out of scope, documented
final_seenstill counts a rejected terminal, so the"final"field on two read endpoints can report an answered correlation when the only terminal event was refused. Presentational — it advances no state — so it is left to a follow-up with aVERIFYcomment naming what should change, per §8.5.5.🤖 Generated with Claude Code