Skip to content

fix(daemon): unified op-log epoch fence on every write path (#157 A1+N1) - #163

Merged
cuttlefisch merged 4 commits into
mainfrom
feat/unified-oplog-fence
Jun 27, 2026
Merged

fix(daemon): unified op-log epoch fence on every write path (#157 A1+N1)#163
cuttlefisch merged 4 commits into
mainfrom
feat/unified-oplog-fence

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

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 epoch
from the wrong authority. This unifies it.

  • A1 (ab65b2c7)kb_member_epoch derives the fence epoch from the signed op-log for anchored KBs
    (the same authority kb_access uses for the role), not the legacy member_roles map. A member admitted
    only via signed op (no member_roles entry) no longer gets epoch 0 → no longer has their valid edits
    rejected.
  • N1 (8233257d) — extracted enforce_epoch_fence (now pub) and applied it on the mesh dialer relay
    path in addition to the hub kb/node_update. One helper, both paths → complete mediation. A signed op
    carrying a stale-epoch yrs client_id can no longer slip in via the peer path.

Tests (principle #14 — adversarial)

  • New kb_member_epoch_reads_oplog_not_legacy_for_anchored_kb — non-zero epoch (7) from the op-log for an
    anchored KB vs 0 for the un-anchored legacy path.
  • 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 holds: the good edit applies
    while the three bad relayed edits (unsigned / non-member / stale-epoch) are rejected. A plain daemon-store
    client_id is now (correctly) fenced as stale-lineage, which is the property under test.
  • 216 daemon tests green; fmt + clippy clean.

A2 re-scope (docs commit dc92bc94)

A2 ("local blocklist ignored") turned out to be a missing feature, not a live bypass: the
MembershipView.blocklist deny-list is designed in membership.rs but unwired everywhere (every derive site
uses MembershipView::default(), no storage, no setter). docs/SECURITY_REVIEW.md corrected; the feature is
tracked 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

  • Daemon-only code change + one docs correction. Editor workspace untouched. Daemon stays key-blind.
  • release:none — no deliverable version bump (keeps dev velocity between stacked PRs).

🤖 Generated with Claude Code

cuttlefisch and others added 4 commits June 27, 2026 16:55
…-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:daemon mae-daemon (persistence, collab hub) release:none Skip version bump on merge security Security / auth / access control

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-023/026: unified op-log epoch fence — derive role+epoch on every write path (A1+N1, HIGH)

1 participant