fix(daemon): unified op-log epoch fence on every write path (#157 A1+N1) - #163
Merged
Conversation
…-039 A1, #157) Security-review finding A1 (HIGH): for an anchored KB `kb_access` derives the member's ROLE from the signed op-log, but the ADR-023 epoch fence read the EPOCH from the legacy `member_roles` map. That map is frozen on a mesh join (B-12), so an op-log-only member has no entry → `epoch_of`→0 → the fence computed the wrong current client_id → every valid edit by any non-epoch-0 member (anyone re-granted / role-changed) was wrongly rejected ("rebase required"). Fix: `kb_member_epoch` derives the epoch from the SAME authority as the role — the signed op-log (`ValidMember.epoch`) for anchored KBs, the legacy `member_roles` for owned/ un-anchored KBs. One source of authority (Zanzibar). The fence now calls it. Adversarial test: a member admitted via a signed op at a non-zero epoch (with NO member_roles entry, as on a mesh join) yields that op-log epoch — not 0 — for an anchored KB, and falls back to the legacy epoch when un-anchored. 215 daemon tests green. Remaining for #157: N1 (apply the same fence on the mesh dialer path) + A2 (thread the local blocklist into verify_content_op). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… N1) The B-19 epoch fence (a granted member must author under their current-epoch client_id; stale-lineage ops are rejected) was enforced only on the hub `kb/node_update` path. The mesh dialer relayed peer ops through `verify_relayed_content_op` (ADR-036 signature + membership) but NOT the epoch fence — so a signed op carrying a stale-epoch yrs client_id could slip in via the peer path. Complete mediation requires the SAME fence on every write path. - Extract `enforce_epoch_fence` from `kb/node_update` (now `pub` — the bin-side dialer calls it) and route the hub path through it (behavior-preserving; 216 daemon tests green). Epoch source is the signed op-log for anchored KBs (A1). - Wire the fence into the dialer inbound relay: the op's author comes from the verified signed header, the node_id from the `kb:` doc name. Collection (membership) docs and unsigned/legacy ops are not content-fenced; with require_signed=true the latter are already rejected upstream. - Tests: the two live-session dialer tests now author the valid owner edit under `derive_kb_client_id(fp, epoch)` — the way a real editor does (kb_ops.rs:1161) — so the SELECTIVE oracle still applies the good edit while the three bad relayed edits are rejected. A plain daemon-store client_id is (correctly) fenced as stale-lineage, which is the property under test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… feature (#162) The deep-review doc (SECURITY_REVIEW.md) framed A2 as "local blocklist ignored in verify_content_op" with a one-line "thread the MembershipView in" fix. On implementation it turned out the local-blocklist deny-list is DESIGNED in membership.rs (derive_valid_members_governed step 7) but ENTIRELY UNWIRED: every daemon derive site passes MembershipView::default(), there is no DocStore storage for blocked principals, and no RPC/command to set one. Because no block can be set, the empty default is consistent everywhere — A2 is NOT a live bypass; the real fix is a daemon+editor feature (durable deny-list + block/unblock RPC + thread every enforcement point + human/AI surface + adversarial tests), now tracked as #162. Correct the finding, the summary, the findings table, and the fix cadence: A1+N1 are the unified epoch fence (shipped, #157); A2 is split out and picks up after the core encryption story (3c/3d). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jun 27, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #157 (A1 + N1). Splits A2 → #162.
What
The ADR-023 (B-19) epoch fence — a granted member must author KB node ops under their current-epoch
client_id; stale-lineage ops are rejected — was enforced on only one of two write paths and read the epochfrom the wrong authority. This unifies it.
ab65b2c7) —kb_member_epochderives the fence epoch from the signed op-log for anchored KBs(the same authority
kb_accessuses for the role), not the legacymember_rolesmap. A member admittedonly via signed op (no
member_rolesentry) no longer gets epoch 0 → no longer has their valid editsrejected.
8233257d) — extractedenforce_epoch_fence(nowpub) and applied it on the mesh dialer relaypath in addition to the hub
kb/node_update. One helper, both paths → complete mediation. A signed opcarrying a stale-epoch yrs
client_idcan no longer slip in via the peer path.Tests (principle #14 — adversarial)
kb_member_epoch_reads_oplog_not_legacy_for_anchored_kb— non-zero epoch (7) from the op-log for ananchored KB vs 0 for the un-anchored legacy path.
derive_kb_client_id(fp, epoch)—the way a real editor does (
kb_ops.rs:1161) — so the selective oracle holds: the good edit applieswhile the three bad relayed edits (unsigned / non-member / stale-epoch) are rejected. A plain daemon-store
client_idis now (correctly) fenced as stale-lineage, which is the property under test.A2 re-scope (docs commit
dc92bc94)A2 ("local blocklist ignored") turned out to be a missing feature, not a live bypass: the
MembershipView.blocklistdeny-list is designed inmembership.rsbut unwired everywhere (every derive siteuses
MembershipView::default(), no storage, no setter).docs/SECURITY_REVIEW.mdcorrected; the feature istracked in #162 (durable deny-list + block/unblock RPC + thread every enforcement point + editor surface +
adversarial tests) and picks up after the core encryption story (3c/3d).
Scope / safety
release:none— no deliverable version bump (keeps dev velocity between stacked PRs).🤖 Generated with Claude Code