Skip to content

id(comms): per-peer frame/byte accounting + structured rate-limit refusal (P0 of #2032) #2244

Closed

Description

@dezren39

From the #2032 investigation (record); this is the prerequisite for every budget in #2033.

Problem

pkgs/id has no per-peer counters. The five existing limiters are keyed by file name, world capability, e-mail address or nonce — none by the remote endpoint public key — and there is no byte accounting anywhere. "Max MB per window, per IP or per ID" therefore cannot be evaluated at all today, and a rate refusal is a prose string with no retry value ("chat rate exceeded; try again later", world/core.rs:955-975).

Work

  • Per-(local node, remote endpoint key) counters for frames and bytes, incremented at the frame read/write boundary in pkgs/id/src/frames.rs before decode.
  • Token bucket over those counters (defaults from the record: 60 frames/s burst 180, 512 KiB/s burst 4 MiB authorized; 10/s burst 30 before authorization; 4 connections per peer).
  • One structured refusal type carrying reason (rate_limited | budget_exhausted | blocked_by_operator | unauthorized) and retry_after_ms (1 s floor, 60 s cap), replacing the prose refusals; HTTP routes map it to 429 + Retry-After.
  • Counters observable (metrics or id peers), so an operator can tune what they can see.

Done when

A peer exceeding the bucket is refused with a machine-readable retry value, the counters are visible, and no existing limit is duplicated — the world chat limiter becomes a consumer of the same bucket.

Parent: #2056 · Investigation: #2032 · Synthesis: #2033

Activity

  1. added
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    on Oct 29, 2026
  2. developing-today commented on Oct 29, 2026

    @developing-today
    Contributor
  3. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — triage record (task 5839, lane D)

    Verdict: CONFIRMED at origin/main (064ce9e). Every claim in the issue checks out against current code; the viable route is the #2033 design's P0-A row (PR #3834, doc/2026-11-01_design_id2033.md, branch design/id-2033 — still OPEN, not yet on main).

    Claim-by-claim:

    1. No per-peer counters exist. The only rate structures in pkgs/id/src are SaveRateLimiter (web/mod.rs:95, keyed by file name) and the world chat limiter (world/core.rs:38 chat_rates: HashMap<u64, VecDeque<Instant>>, keyed by world participant id). No counter is keyed by remote endpoint public key, and there is no byte accounting anywhere. Confirmed.
    2. The frame boundary is the right charge point, and it is uncharged today. frames.rs read_frame (L150–165) reads the 4-byte length, enforces the 1 MiB cap (MAX_FRAME_BYTES, L16–20), then allocates the body — the design's charge point (after length prefix, before allocation/decode) exists as a clean seam. Writers all funnel through encode_frame/write_frame (L27–39). Confirmed.
    3. Rate refusal is prose, with no retry value. world/core.rs:1000 — "chat rate exceeded; try again later" (the issue cited L955-975; the line has drifted to 1000). The web save path returns 429 with a prose body and no Retry-After header (web/routes.rs:1218). Several local Refusal enums exist (event_store/ingest.rs:109, membership/layer.rs:58, groups/fold.rs:44, directory.rs:94; directory_auth.rs's RateLimited carries a prose String) — none is a shared structured rate/budget refusal with reason + retry_after_ms. Confirmed, with that nuance: the work is one shared contract, not another local enum.
    4. Defaults are as the issue states — id: investigate: comms abuse controls — blocks, rate limits, and per-counterparty budgets #2032/id: design: abuse-control primitives v1 for ID comms #2033: 60 frames/s burst 180 authorized, 10/s burst 30 pre-authorization, 512 KiB/s burst 4 MiB, 4 connections per peer; retry 1 s floor / 60 s cap; endpoint bucket shared across that peer's connections (opening more streams must not multiply allowance).

    Implementation route — two phased, independently mergeable packets, enqueued on queue repo-health-code-pilot:

    • Packet A (core) — dedupe repo-health:v1:fix:developing-today/code:issue:2244:p0a-core: shared abuse evaluator module — typed subjects (endpoint / connection), token buckets (frame + byte) with the defaults above, one structured refusal type (rate_limited | budget_exhausted | blocked_by_operator | unauthorized, retry_after_ms bounded 1 s–60 s), and a counter registry with a snapshot API for observability. No behavior change to existing paths.
    • Packet B (integration) — dedupe repo-health:v1:fix:developing-today/code:issue:2244:p0a-integration (after A): charge counters at the frames.rs seam with the peer's EndpointId threaded from the adapters; HTTP mapping 429 + Retry-After at the existing 429 sites (web/routes.rs, web/world_ws.rs:350); world chat limiter becomes a consumer of the same bucket and returns the structured refusal; counters surfaced (id peers / metrics).

    Invariants the implementation must hold (from #2033): a frame is charged before body allocation/decode; the 1 MiB hard cap is retained, not duplicated; endpoint allowance is shared across connections; a refusal sheds the request/stream, never the whole peer connection; semantic caps (chat, save) keep their semantics — they consume the shared accounting, they are not relabeled as transport frames.

    Drew questions: none. #2033 records "None" for its open choices (scoped defaults chosen; relay-attested IP deferred as a separate privacy decision), and nothing in this issue needs a Drew ruling to start Packet A.

  4. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — triage addendum (task 5839): packet identifiers, corrected

    The dedupe keys named in the triage record above used phase suffixes the queue's key grammar does not accept, so the phases were filed as their own issues (the fleet's established pattern) and the packets enqueued against them:

    Triage task 5839 completes as triaged. Drew questions: none (per the triage record).

  5. added
    queue/queuedHas a queued task in the Supabase agent queue
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  6. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — state: picked-up (triage task 5907, OpenChamber scheduled lane)

  7. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — triaged (task 5907, OpenChamber scheduled lane)

  8. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — state: picked-up (triage task 7867, OpenChamber scheduled lane ec118cad)

  9. dezren39 commented on Nov 3, 2026

    @dezren39
    MemberAuthor

    agent-note:v1 — triaged / done (task 7867, OpenChamber scheduled lane ec118cad)

    Verdict: implemented and merged at main 1e3763fa; closing with evidence. The prior triage (task 5839/5907) scoped the work and routed the phases; both phases and the design record have since landed, and this pass verified the shipped code rather than re-planning it.

    Claim-by-claim at refs/remotes/origin/main (1e3763fa):

    1. Per-peer frame/byte counters at the frame boundary — done. CounterRegistry in pkgs/id/src/abuse.rs keys counters by (NodeId, EndpointId) and records both directions; pkgs/id/src/frames.rs exposes charge_frame / read_frame_len + read_frame_body / read_frame_charged / write_frame_charged, so a charged reader charges after the 4-byte length prefix and before body allocation, with MAX_FRAME_BYTES retained (not duplicated). (PR feat(id): shared abuse evaluator core — buckets, structured refusal, counter registry (#3881) #3891 bc76a7e core, PR feat(id): P0-A integration — frame-seam charging, HTTP 429+Retry-After, chat limiter consumer, counter visibility (#3882) #3960 762f32a integration.)
    2. Token buckets with the specified defaults — done. AbusePolicy::default() (abuse.rs:248) is authorized 60 frames/s burst 180 and 512 KiB/s burst 4 MiB, pre-authorization 10/s burst 30, max_connections_per_peer: 4. Endpoint allowance is owned by the endpoint, so extra connections do not multiply it (world_net.rs shares one evaluator per remote EndpointId).
    3. One structured refusal with a bounded retry, prose replaced, HTTP 429 + Retry-After — done. abuse::Refusal / RefusalReason { rate_limited, budget_exhausted, blocked_by_operator, unauthorized } with retry_after_ms clamped to 1 s–60 s (none for operator blocks); JSON body + Retry-After at web/routes.rs:1326 and web/world_ws.rs:491-506. The world chat limiter is now a consumer of the same evaluator (world/core.rs chat_abuse, burst/rate preserved); the prose string is gone (git grep "chat rate exceeded" → no hits).
    4. Counters observable — partial, tracked. Counters are correct and exposed as WorldProtocol::abuse_counters() (world_net.rs:156), but no shipped surface prints them — no CLI verb and no metrics route calls it, so an operator cannot yet read a counter value. That last mile is filed as id(abuse): surface per-peer frame/byte counters to operators (metrics or id peers) #4918 with queue task 8033, rather than held here: nothing in the counter/refusal work itself is missing.

    Residual items, deliberately not part of this issue: #2247 (rolling-window budgets) now has the counters it was blocked on, and #3927 (oversize request refused as a temporary BudgetExhausted) is a real bug in the shipped evaluator, both already tracked.

    Disposition: close #2244 as completed. No duplicate packets created; no held/Drew-owned artifacts touched.

    Links: #3881 · #3891 · #3882 · #3960 · #3834 · #4918 · #2247 · #3927

  10. added
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Nov 3, 2026
  11. developing-today commented on Nov 3, 2026

    @developing-today
    Contributor

    agent-note:v1
    state: triaged (coverage pass)
    Coverage pass 2026-11-03: reviewed in the fleet-wide per-issue pass (drift / overlap / already-handled / stale / blockers / ordering). Verdict: still live; queued in repo-health-code-pilot as triage at priority 30 (priority/none). If this is already handled at current main, the triage closes it with evidence; if it needs splitting, the triage files the split.
    Found via: coverage pass 2026-11-03

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)queue/queuedHas a queued task in the Supabase agent queuesource/creatorFiled by the new-issue-creator jobstatus/needs-triageNot yet triaged; needs a first looktask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions