diff --git a/doc/2026-11-01_design_id2033.md b/doc/2026-11-01_design_id2033.md new file mode 100644 index 000000000..404671aa7 --- /dev/null +++ b/doc/2026-11-01_design_id2033.md @@ -0,0 +1,153 @@ +# Design: abuse-control primitives v1 for ID comms + +- **Date:** 2026-11-01 +- **Status:** decision-ready design; implementation has not started +- **Issue:** [#2033](https://github.com/developing-today/code/issues/2033), under epic #2056 +- **Evidence snapshot:** checkout `design/id-2033` at `a9e976ac245d94682ff7ada8dd5562cc58c9b00f` +- **Inputs:** #2032 investigation (merged as [PR #2243](https://github.com/developing-today/code/pull/2243)); #2030 trust/visibility decision (merged as [PR #2801](https://github.com/developing-today/code/pull/2801)) + +## Decision to make + +Choose a small, implementable set of local policy primitives for ID's mixed comms paths: how a node identifies a counterparty, blocks or mutes it, accounts for resource use, returns a useful refusal, and keeps those controls separate from the signed-object trust model. The issue asks for block/limit/budget objects, enforcement at client/relay/server, composition with accept-all-signed-object behavior, and the operator's quick button. + +## Recommendation + +Build one **node-local abuse-policy module** with typed subjects and shared accounting, then call it through thin protocol adapters at the earliest point each path has the facts it needs. Keep policy separate from signed graph data and from event validity: + +1. Count transport frames and bytes against both a connection and the remote Iroh `EndpointId`; enforce before frame-body allocation/decode where the shared frame codec is used. +2. After signature verification, optionally account content/storage against the distinct event `AuthorKey`. Do not infer an account or author key from an endpoint key. +3. Treat `Block endpoint` as a node-local transport/serving decision, and `Mute author` as a local view decision. Neither changes whether a signed event is valid or what another node stores. +4. Keep persisted operator rules/configuration separate from ephemeral counters. Persist blocks in a node-local control table; put numeric defaults and overrides in a wired `[abuse]` section of `id-server-config.toml`. Rate buckets and live counters are in-memory abuse controls, not billing records; restarting the process resets them, while persisted rules remain. +5. Use one structured refusal contract across adapters. Include a bounded retry duration for temporary rate/budget refusals; permanent blocks have no retry duration. Map HTTP refusals to `429` and `Retry-After`; use the protocol's refusal frame where it has one, otherwise a stable application close code. + +This is the smallest useful composition: a common decision and accounting model without pretending that QUIC, HTTP/WebSocket, content-author identity, and direct IP are the same seam or identity. + +## Verified context at this checkout + +The code evidence below is pinned to the snapshot SHA in the header. That matters because some statements in the October 29 #2032 inventory were corrected by later code/review. + +| Current evidence | Design consequence | +| --- | --- | +| `id serve` registers separate Iroh protocols, including metadata, blobs, gossip, docs, event sync and world traffic ([`commands/serve.rs:973-999`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/commands/serve.rs#L973-L999)). The web listener is a separate surface. | There is no single HTTP middleware that can police all ID comms. Use one policy module with adapters at the transport/protocol seams. | +| The shared Iroh frame codec caps frames at 1 MiB and reads the length before allocating the body ([`frames.rs:16-20,150-165`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/frames.rs#L16-L20)). | Retain this structural cap. The new byte/frame decision belongs after reading the length prefix and before allocating/decoding, not as another duplicate size cap. | +| Event sync already reports per-session sent/received and wire-byte counts ([`event_store/sync.rs:103-130`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/event_store/sync.rs#L103-L130)); reconciliation also has a 64 MiB per-session read budget ([`event_store/range.rs:43-57`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/event_store/range.rs#L43-L57)). | Reuse these outcomes and retain the session cap. They do not provide rolling, cross-session per-peer admission/accounting, so add that shared counter rather than another per-session limit. | +| `PullGate` is checked before event data is sent and refuses by closing the connection ([`event_store/sync.rs:528-560`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/event_store/sync.rs#L528-L560)). The production event-sync gate is deliberately `Open`; `id serve` does not load a per-world policy there ([`event_sync.rs:22-25,70-74`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/event_sync.rs#L22-L25)). | Do not describe `PullGate` as unenforced. Add the abuse decision at the existing serve seam; separately wire the desired configuration. Do not mistake an open default for an absent gate. | +| Ingest verifies canonical encoding/signature and chain rules; its outcomes include malformed/frozen/cap refusals, waiting, and stored-but-voided events ([`event_store/ingest.rs:1-27,107-177`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/event_store/ingest.rs#L1-L27)). The later #2030 decision is validity at ingest → stored evidence → per-view writer-set projection ([#2030 final record](https://github.com/developing-today/code/blob/74093ef4dfbf4ccdfaed26e4107e66f02e54d1ee/doc/2026-10-30T19-11-04Z_investigation_trust_visibility_model/2026-10-30T19-11-04Z_investigation_trust_visibility_model.md#L44-L68)). | Preserve validity and resource refusals as separate outcomes. “Accept all signed objects” is not “accept malformed data” or “ignore storage/session caps”; it means abuse policy must not impersonate an authority/validity verdict. | +| Transport and content identities are distinct: `AccessPolicy` is keyed by Iroh `EndpointId` ([`access.rs:31-38,46-56`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/access.rs#L31-L56)); event `AuthorKey` is a separate 32-byte key ([`waiting.rs:31-38`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/waiting.rs#L31-L38)); group `Key` is another 32-byte account key ([`groups/mod.rs:59`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/groups/mod.rs#L59)); directory principals can be account IDs ([`directory_auth.rs:33-37`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/directory_auth.rs#L33-L37)); collab WebSockets authenticate a separate `identity_client_id` ([`web/collab.rs:697-742`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/web/collab.rs#L697-L742)). The envelope protocol trusts the payload signature, not the delivering Iroh node ([`envelope_net.rs:1-7`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/envelope_net.rs#L1-L7)). | Name the subject kind in every rule and counter. No implicit endpoint↔author↔account↔web-client join is part of v1. | +| Existing limits are feature-local: world chat is 50 messages per participant per 10 seconds ([`world/mod.rs:53-55`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/world/mod.rs#L53-L55), [`world/core.rs:976-1007`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/world/core.rs#L976-L1007)); waiting events are capped per author and in total ([`waiting.rs:34-49`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/waiting.rs#L34-L49)); directory pending friend requests are capped per account ([`directory.rs:305-306,1248-1254`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/directory.rs#L305-L306)). | Keep specific semantic caps; route them through or report them to shared accounting only where the units/subject actually match. Do not replace these with a lossy generic “one limit fits all.” | +| The collaboration handler decodes and applies `Steps` messages in its receive loop ([`web/collab.rs:978-1045`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/web/collab.rs#L978-L1045)). The PR #2243 review noted Axum's inherited WebSocket size ceiling, so the old inventory's claim of no message-size ceiling is too broad. | Add per-peer rate/accounting to the collab path, but first verify and retain the actual Axum size ceiling; do not add an arbitrary second size cap by assumption. | +| Web save already returns HTTP 429 with a human-readable remaining duration ([`web/routes.rs:1200-1221`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/web/routes.rs#L1200-L1221)); world WebSocket refusals also map rate limiting to 429 ([`web/world_ws.rs:344-360`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/web/world_ws.rs#L344-L360)). The save limiter evicts expired keys ([`web/mod.rs:115-138`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/web/mod.rs#L115-L138)). | Add the standard `Retry-After` header and common reason mapping; do not re-add the stale “save limiter never evicts” issue from the first #2032 pass. | +| `PullPolicy` is local serving policy, defaults to `Open`, and config parsing exists; the sync module notes that production does not yet load that policy ([`pull_policy.rs:1-19`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/pull_policy.rs#L1-L19)). `ServerConfig` has parse/load methods ([`server_config/mod.rs:150-165`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/server_config/mod.rs#L150-L165)). | Policy parsing alone is not operator control. The implementation phase must wire configuration into `id serve` before claiming that configured budgets or serving policy are active. | + +## Primitive shapes and semantics + +These are conceptual interfaces, not a commitment to these Rust names. + +### Typed local rules + +| Subject kind | v1 meaning | Enforcement | +| --- | --- | --- | +| `EndpointId` | **Block endpoint**: stop new sessions to/from this endpoint, close its current sessions, stop serving it, and remove its local subscriptions. The peer-list quick action creates this rule. | Iroh accept/dial and serve/subscription adapters. | +| `AuthorKey` | **Mute author**: hide that author's objects from the operator's local default views. It does not erase, reject, or stop replicating valid signed events. | Local view/projection only. | +| Account key/ID | Mute that account's attributed content in account-aware views. Do not infer an account from the endpoint that delivered an artifact; endpoint hosting and account identity are separate ([`artifact.rs:147-163`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/artifact.rs#L147-L163)). | Account-aware local view only, where the account attribution is verified. | +| Authenticated `identity_client_id` | Mute or rate-limit that WebSocket identity; it is not an Iroh endpoint or an account key. | Collab/WebSocket adapter only. | +| Direct `IpAddr`/CIDR | Deny at the listener for a directly observed address only. Never infer a relayed peer's address. | Listener/host firewall adapter; defer to the later phase. | +| Gossip `TopicId` | Stop local subscribe/pull/serve for that exact gossip topic. | Gossip subscription/serving adapter; defer to the later phase. | +| Metadata tag | A separate typed rule from gossip topic; suppress or avoid pulling matching metadata only where a tag-aware policy path exists. | Tag/view or pull adapter; defer until its semantics are specified and tested. | + +Each persisted rule is node-local and carries `{subject_kind, value, created_at, reason, expires_at}`; `expires_at = null` means permanent. Rules are not signed graph objects and are not federated. An endpoint ID, author key, account key/ID, web-client ID, IP, gossip topic, and metadata tag are not aliases. The #2243 review specifically distinguishes endpoint denies from account/author rendering blocks ([review finding](https://github.com/developing-today/code/pull/2243#discussion_r4411443930)). If a peer row exposes multiple identities, “Block endpoint” and “Mute author/account” remain separate, explicit actions. A peer without an established author/account identity can still be blocked by endpoint. + +For v1, use a per-node operator control table for live block/mute rules, and a wired `[abuse]` table in `id-server-config.toml` for numeric defaults and explicit budget overrides. The config's current parse/load support is not enough by itself; `id serve` must consume it. Keep counters in memory and label them as runtime enforcement/diagnostic counters, not durable billing or audit totals. On restart, counters reset; persisted operator rules and configured limits do not. + +### Shared accounting and limits + +One `AbusePolicy` evaluator owns token buckets, rolling process-epoch byte/frame budgets, concurrent-session counts, and per-subject overrides. Adapters supply typed subjects and direction (`inbound`/`outbound`); they do not each invent their own counter map. Share the endpoint bucket across a peer's connections so opening more streams does not multiply its allowance. Use an additional connection bucket for fairness. The HTTP/WebSocket adapter uses the authenticated `identity_client_id` when present, otherwise a per-socket pre-auth bucket and a directly observed client IP only when the listener exposes it; it never calls that subject an Iroh endpoint or trusts an arbitrary forwarded-IP header. + +“Pre-authorization” ends only when that protocol's own authentication/authorization succeeds; an allow-list for write operations does not authorize unrelated reads or sessions ([`access.rs:1-17,46-80`](https://github.com/developing-today/code/blob/a9e976ac245d94682ff7ada8dd5562cc58c9b00f/pkgs/id/src/access.rs#L1-L17)). Every ID node applies its own policy on work it initiates and work it accepts/serves: the dialer charges outbound bytes, the receiver charges inbound bytes before parsing, and a relay charges only the traffic it handles. The browser is a configuration/diagnostic client, not a trusted enforcement point. A relay does not get the original peer's IP from this design. + +Start with the defaults proposed by #2032 / the already-filed implementation issues; make each configurable and observable: + +| Control | Initial default | +| --- | --- | +| Authorized endpoint frame rate | 60 frames/s, burst 180 | +| Pre-authorization frame rate | 10 frames/s, burst 30 | +| Authorized endpoint bytes | 512 KiB/s, burst 4 MiB | +| Concurrent connections per endpoint | 4 | +| App-wide inbound/outbound budgets | 32 MiB/s + 20 GiB/hour inbound; 64 MiB/s + 40 GiB/hour outbound | +| Endpoint byte budget | 100 MiB/hour, with 8 MiB/minute burst | +| Direct-IP byte budget | 250 MiB/hour, only when the direct address is observable; later phase | +| Specific-counterparty override | Typed subject plus max bytes and max messages/frames per window; no override unless configured | + +The limits are starting safety defaults from the investigation, not billing promises. A message/frame is counted at the adapter's named layer; do not compare a WebSocket message count with an event count without an explicit conversion. The collab adapter uses the common evaluator and the investigation's 120 messages/s, burst 360 proposal, subject to the existing Axum message ceiling. Rate and budget refusals report a bounded retry duration (1 s floor, 60 s ceiling, as proposed in #2032). A specific override is checked before its general endpoint/IP budget, then both the applicable override and aggregate limits are charged; an override can be stricter but cannot raise the node-wide ceiling. + +### Refusal contract + +Use one typed result with: + +```text +reason: rate_limited | budget_exhausted | blocked_by_operator | unauthorized +retry_after_ms: optional positive duration +``` + +`retry_after_ms` is present for temporary rate/budget refusals and absent for permanent operator blocks or authorization failures. HTTP adapters map temporary refusal to `429` plus `Retry-After`; existing HTTP 429 sites adopt this mapping. Protocols that can return an ID refusal frame do so before ending the request; otherwise close only the affected request stream with a stable application code. Do not close an entire peer connection for one ordinary rate-limit hit. A permanent endpoint block closes that endpoint's sessions. The refusal reason describes a work/serving decision, not whether a signed object is valid. + +The direct-IP and external-list limits are deliberate: #2032 notes that relay paths do not expose the original peer IP and recommends against shipping unreviewed “common” block lists because those lists age quickly and relayed peers evade them ([#2032 investigation, §3–4](https://github.com/developing-today/code/blob/0c10ee8878051cfbe63f5ac0ccf7f6cbc74e7847/doc/2026-10-29T10-55-00Z_investigation_comms_abuse_controls/2026-10-29T10-55-00Z_investigation_comms_abuse_controls.md#L178-L255)). + +## Composition with trust and serving policy + +The newest recorded trust decision is #2030's merged PR #2801: **validity at ingest → stored evidence → per-view writer-set projection**. It supersedes the preliminary #2030 investigation wherever that first version overclaimed; its final correction specifically says per-author grouping does not solve a multi-key Sybil flood. The current `ingest.rs` still shows why the phrase “accept all signed objects” must be scoped: malformed, frozen, and over-cap events can be refused, while some events are stored but voided. The abuse layer must not add a new author-authority rejection at receipt. + +| Action | May stop work/serving? | May change store validity/application? | May change this node's view? | +| --- | --- | --- | --- | +| Endpoint block | Yes: session, subscription, or serving | No | Only as a consequence of no new traffic; not an author judgment | +| Author mute | No network denial | No | Yes, local projection only | +| Rate/budget limit | Yes: refuse a request or defer work | No; it is not an invalid-object verdict | No authority effect | +| IP/topic/tag rule | Yes, at its typed listener/subscription/view seam | No | Only the defined local projection/serving result | +| Signature/canonical/chain verification | Refuses malformed or invalid input | Yes, per existing ingest rules | Derived from store state and writer-set view | + +Resource policy is allowed to say “not now,” “no budget,” or “this node will not serve this peer.” It must not say that an otherwise valid signed object is invalid, and a local mute/block must not rewrite the object graph or another node's projection. Existing pull/session resource checks remain independent; in particular, retain the current 64 MiB reconciliation session budget and reuse the already-enforced `PullGate` rather than adding parallel checks. + +## Viable options and trade-offs + +1. **Keep per-feature limiters and add one-offs.** Lowest initial migration cost, and preserves local semantics such as world chat's participant cap. Rejected as the overall design: it cannot express a shared endpoint budget, cross-connection fairness, consistent retry information, or the counterparty overrides in #2032. Preserve semantic feature caps, but make the common evaluator their shared resource accounting seam. +2. **Put all protection in HTTP/reverse-proxy middleware.** Good for web routes and listener IP controls, and can standardize HTTP 429. Rejected as the primary design because `id serve` registers Iroh/QUIC protocols outside HTTP; it cannot police frame sync, blobs, gossip, or world traffic. +3. **One shared local policy evaluator with protocol adapters (recommended).** Gives one typed rule/counter model and observable refusal semantics while preserving protocol-specific seams. Costs an initial adapter migration and careful distinction between endpoint, author, account, and direct-IP identities. This is the best leverage/locality trade: rate/budget behavior is implemented once; callers only supply facts available at their seam. +4. **Replicate block lists or reject blocked authors during ingest.** Appears to make moderation uniform. Rejected: nodes need local operator choices, external lists age quickly and can be evaded through relays ([#2032 investigation, §4](https://github.com/developing-today/code/blob/0c10ee8878051cfbe63f5ac0ccf7f6cbc74e7847/doc/2026-10-29T10-55-00Z_investigation_comms_abuse_controls/2026-10-29T10-55-00Z_investigation_comms_abuse_controls.md#L245-L255)), and ingest-time authority filtering conflicts with #2030's per-view writer-set model and destroys/rejects evidence rather than suppressing its local effect. + +## Invariants and implementation acceptance criteria + +- Every rule and counter names its subject kind. Endpoint identity is not silently treated as author/account/web identity; changing an endpoint key cannot erase a content/account counter when that identity is independently known. +- Endpoint buckets are shared across that endpoint's concurrent sessions; per-connection limits do not multiply the endpoint-wide allowance. Node-wide budgets remain effective even if peers rotate keys. +- A frame is charged before body allocation/decode where the shared frame seam applies; the existing 1 MiB hard frame cap is retained. No second event-sync session cap is introduced. +- Existing semantic caps (waiting-per-author, pending friend requests, world chat) keep their semantics and produce a structured refusal where their caller can expose one; their counts are not mislabeled as transport frames. +- A rate/budget refusal has a machine-readable reason and a bounded, positive retry duration. HTTP 429 responses include `Retry-After`. The affected request/stream is shed without unnecessarily closing unrelated peer sessions. +- The one-click peer-row action persists an endpoint block, closes/denies that peer's sessions, and makes the block visible/removable from the operator's block list. “Mute author” remains a distinct action. +- Given the same valid signed events, enabling a local author mute does not change which events the store accepts, stores, or marks voided; it changes only the local projection. Tests also prove another unblocked source can still provide the same valid event. +- Direct-IP rules are tested only for directly observed addresses; a relayed connection never inherits an asserted client IP in v1. +- Budgets and block rules are active in production only after `id serve` loads their config/store. Tests cover config parse, defaults, restart semantics, and live rule updates. +- Counters are visible at least through the operator status surface or logs with subject kind, direction, usage, and refusal reason. Do not claim that an unobservable limit is tunable. + +## Phased implementation plan + +Each row is a separately mergeable change. Existing build follow-ons are referenced rather than recreated; this design task does not file or modify other issues. + +| Phase | Independently mergeable step | Depends on | Done when | +| --- | --- | --- | --- | +| **P0-A** | Shared typed accounting + refusal contract; endpoint/connection frame and byte buckets; HTTP mapping adapter. Aligns with [#2244](https://github.com/developing-today/code/issues/2244). | None | Shared counters are charged before decode where possible; existing 429 paths emit `Retry-After`; tests prove endpoint-wide allowance is shared over connections and the current frame/session caps remain. | +| **P0-B** | Collaboration WebSocket rate adapter and operator-facing usage/refusal diagnostics. Key authenticated sessions by `identity_client_id`; bound anonymous pre-auth work per socket (and trusted direct IP where available). Keep the actual inherited Axum size ceiling; do not duplicate or weaken it. | P0-A's evaluator/refusal | A burst and sustained collab flood are bounded by the available typed subject; the refusal is observable; tests show normal editing traffic still passes. | +| **P0-C** | Persisted node-local endpoint block + author mute, peer-row quick action, and block-list/unblock view. Aligns with [#2245](https://github.com/developing-today/code/issues/2245). | Rule model; may merge independently from P0-B | Blocking denies new/current endpoint sessions and serving; muting affects only local views; restart preserves rules; no signed event is rewritten. | +| **P1** | Wire `ServerConfig` into `id serve`; add `[abuse]` defaults and typed endpoint/author budget overrides; enforce app-wide and per-endpoint byte/message windows. Aligns with [#2247](https://github.com/developing-today/code/issues/2247). | P0-A | Config is active and validated at startup; an override constrains only its named counterparty in addition to aggregate ceilings; usage and retry values are testable and visible. | +| **P2** | Direct-listener IP deny/budget and distinct `GossipTopicId` / metadata-tag rules at their own adapters. Revisit import UX only after the target formats are defined. | P0-C and P1 policy/config seam | Direct IP works only on a direct path; topic and metadata tag are not conflated; all rules are local, inspectable, and reversible. | + +**Explicitly out of v1:** relay-attested client IP (privacy/protocol change), externally sourced “common” block-list feeds, block-list federation/reputation, account↔endpoint identity linking, billing-grade durable usage metering, and changing the signed event format. No PoC or permanent implementation is included in this design-only issue. + +## Questions for Drew + +**None.** The design resolves the open implementation choices with scoped defaults: endpoint key for transport admission, author key for local muting/content accounting, node-local rules, frame plus byte accounting, and request-stream refusal without peer-wide disconnect. Relay-attested IPs are deferred rather than silently added because that would introduce a separate privacy decision. Initial numeric limits are the #2032 proposals, configurable and observable; no owner preference is needed to start implementation. + +## Sources and decision precedence + +- #2033 issue body and latest resume note (2026-11-01): defines the synthesis scope and requires the #2032/#2030 trust composition. +- #2032 investigation and its correction pass, merged in PR #2243: control inventory, proposed defaults, and P0/P1/P2 order. Where the old inventory conflicts with this checkout, the pinned source above wins (notably event-sync session counters/64 MiB budget, enforced-but-open `PullGate`, HTTP 429s, Axum's inherited WebSocket cap, and fixed `SaveRateLimiter` eviction). +- #2030 final trust/visibility decision, merged in PR #2801 on 2026-10-30: validity at ingest, stored evidence, per-view writer-set projection; the final Sybil correction supersedes the earlier draft. +- `pkgs/id` evidence is linked inline at the exact checkout SHA recorded above.