RFC: Scopes — Visibility Boundaries Within a Peer

Status: Draft (v1) Owner: vineeth Last updated: 2026-07-01 Tracking: DEV-1970

Working name: this RFC uses scope. Naming is Open Question 1 — space and container were considered (see §9).


1. Problem Statement

A customer (sauna.ai) runs one workspace per environment and one peer per end-user. Their product has spaces — groupings of sessions (therapy, work, a shared project) with per-user visibility rules. A session can belong to multiple spaces, and membership changes over time.

They need reads that respect the current space: dialectic (peer.chat), representation/session.context, and search should only draw on data traceable to the space’s sessions. Writes should keep feeding one global representation per peer — they explicitly do not want a workspace per space or a peer per space.

Their exact ask:

We need sessions_ask-style behavior that answers across only the dynamically allowed session set for the current space, but peer.chat appears to accept only a single session_id or a target, not a session allowlist/filter.

This is not primarily an authorization problem — the customer holds the API key and their end-users never touch Honcho. It is a query-time visibility problem over derived data:

  • An explicit conclusion belongs to one session (documents.session_name), so filtering it is easy.
  • Higher-order conclusions (dreamer deductions/inductions) blend premises across sessions. A visibility boundary has to follow provenance, not just a column.
  • The dreamer also consolidates duplicates, so “which session did this fact come from” can be a set.

We expect this ask to recur (“holistic representation, arbitrary read boundaries”), so the answer should be a product capability, not a support workaround.

Why the current state is insufficient

Grounded in repos/honcho at time of writing:

SurfaceScoping todayGap
Dialectic conclusion recall (prefetch + search_memory)(observer, observed) only (src/dialectic/core.py:151-230, src/utils/agent_tools.py:1656)No session or filter parameter at all
Dialectic message toolsSession, or all sessions the observer belongs to (get_peer_session_names)Correct pattern; not exposed as an allowlist
Working representationsession_name applied only to the “recent” query; semantic and most-derived queries ignore it (src/crud/representation.py:341-424)limit_to_session leaks cross-session conclusions today — a standalone bug
honcho.searchMessages only; filter DSL supports session_id: {in: [...]} and metadataAlready sufficient for space-scoped search
Conclusions list/queryFull filter DSL incl. session_idWorks, but only covers explicit provenance
Documentssession_name, source_ids (premise tree), internal_metadata.message_idsProvenance exists; nothing enforces it at read time

2. The Core Trade: Dynamic vs. Materialized

Dynamic filtering and higher-order utility pull in opposite directions:

  • A query-time session allowlist is maximally dynamic (overlapping spaces, membership changes, nothing to re-index) but can only select from conclusions that already exist. It cannot produce space-level dreams.
  • The dreamer is a materializer: it precomputes structure over a corpus. Dreaming within a space requires the space to exist durably in storage.

Honcho already has exactly one unit of materialized memory — the collection (observer, observed) — and the dreamer already runs per-collection. So per-scope dreaming is not a new capability; it falls out the moment a scope has its own collection.

The design therefore has two arms behind one API surface:

  1. Named scopes (materialized): a durable scope with its own collection and dream layer. Implemented as an observer peer — no new storage primitive.
  2. Dynamic session allowlist (filtered): an ad-hoc sessions: [...] parameter on reads, for computed access (unions of spaces, ABAC-style policies). Explicit knowledge only, fail-closed on derived conclusions.

Materialize the few, filter the many.

3. Goals

  1. peer.chat, representation, session.context, and search accept a scope (named) or session allowlist (dynamic) and never read outside it.
  2. Named scopes get their own dream layer: deductions, inductions, and dedup computed within the boundary.
  3. One global peer per user is preserved; ingestion is unchanged.
  4. Sessions belong to any number of scopes; membership can change after the fact with no LLM re-derivation.
  5. Developers never see the words observer or observed. The facade is the interface.
  6. No new storage primitive and no required migration in v1.

4. Non-Goals

  1. Principal management — users, groups, roles, grants, SSO. Honcho verifies claims; identity is the app’s (or IdP’s, or Groudon’s) concern. See §8.
  2. Enforcement in v1 — scope-bound API keys are the natural follow-on (§8), not this spec.
  3. Per-scope extraction instructions — would break the copy-equals-derive invariant (§6.3) and must arrive together with a true re-ingestion primitive. Future work.
  4. Scope ACLs inside Honcho — which end-user may read which scope stays app-side.

5. Design: the Scope Facade

5.1 Developer surface

Management (new thin routes /v3/workspaces/{w}/scopes/..., mirrored in SDKs):

const therapy = await honcho.scope("therapy") // create-or-get
await therapy.addSessions([s1, s2]) // membership + background backfill
await therapy.removeSessions([s2]) // membership + staleness reconcile
await therapy.sessions() // list members
await honcho.scopes.list()
 
// common path: declare at session creation, no backfill ever needed
await honcho.session(id, { scopes: ["therapy"] })

Query side — one option on the reads that exist today:

await user.chat(q, { scope: "therapy" }) // named: scope collection, dreams included
await user.chat(q, { sessions: [s1, s2, s3] }) // dynamic: filtered, explicit-only, fail-closed
await user.chat(q, { scope: ["therapy", "journal"] }) // union → executes via the dynamic arm
 
await session.context({ peerTarget: u, scope: "therapy" })
await user.getRepresentation({ scope: "therapy" })
await honcho.search(q, { scope: "therapy" }) // resolves to the scope's session set

OpenAI-compatible endpoint: X-Honcho-Scope header, alongside the existing session/target headers.

The grammar is deliberate: the user’s peer stays the subject; the scope is a modifier (“query my user, restricted to this lens”). The raw observer-peer pattern expresses the same thing backwards (spacePeer.chat({target: user})), which is the main source of confusion with the pattern today.

5.2 Under the hood: a scope is an observer peer

Facade conceptExisting storage
The scope (id, display metadata)a peers row — reserved namespace, kind: scope flag in configuration, h_metadata for description
Scope ↔ session membershipsession_peers rows (observe_others: true, observe_me: false; joined_at/left_at already exist)
The scope’s memorythe (scope, user) collection + documents, auto-created
Scope-level dreamingthe dreamer’s existing per-collection operation
Staleness on removalinternal_metadata flags (same design composable-peers assumes)

The access boundaries fall out of existing semantics rather than new filter code:

  • Conclusion recall: chat(q, {scope}) runs the dialectic with observer = scope-peer, observed = user → recall is confined to the scope’s collection by ordinary collection semantics.
  • Message recall: dialectic message tools already restrict to sessions the observer belongs to — for a scope-peer, that is exactly the scope’s session set.
  • Derivation cost: the deriver runs one LLM extraction per (session, observed) and fans identical observations out to each observer’s collection. Extra scopes cost document rows, not LLM calls.
  • Performance: scope collections are strictly smaller than the global collection, so scoped dialectic prefetch gets faster, not slower.

Litmus test: delete the facade routes and what remains is a valid, hand-built observer-peer setup. No data written by v1 is in a shape only the facade understands.

5.3 Guardrails (server-side, keyed off kind: scope)

  1. A scope-peer cannot author messages.
  2. A scope-peer cannot be a chat target (observed).
  3. peers.list() excludes scope-peers by default; peers.list({ kind: "all" | "scope" }) opts in. Scopes surface primarily via scopes.list() and a dedicated dashboard tab — categorized, not hidden.
  4. Scope-peers are excluded from composable-peers aggregation unconditionally (see §7).
  5. No representation is formed of a scope-peer (observe_me: false at every membership).

Default exclusion from peers.list() is a compatibility requirement, not aesthetics: existing code iterates peers assuming each row is a user or agent (peer pickers, per-peer batch jobs). Creating a scope must not change what those loops see.

5.4 Dynamic arm semantics (fail-closed)

For { sessions: [...] } (and multi-scope unions):

  • Messages: recall restricted to the allowlist (intersected with any observer membership rules).
  • Explicit conclusions: included iff session_name ∈ allowlist.
  • Derived conclusions: v1 includes none whose provenance is not provably inside the allowlist — in practice, dream-produced documents (session_name IS NULL) are excluded. Errs toward hiding, never leaking.
  • Applied uniformly at every conclusion-recall chokepoint: dialectic prefetch, search_memory, and all three RepresentationManager query paths. (Phase 0 fixes the existing limit_to_session partial-scoping leak with the same uniform treatment.)

A later, optional migration — materializing each document’s provenance closure as a session_names[] array (GIN), maintained incrementally at write time — upgrades the dynamic arm to include derived conclusions whose entire root set is in the allowlist. Named scopes never need it; their derived layer is physically separated per collection.

Known subtlety for that upgrade: source_ids today doesn’t distinguish consolidation (duplicate merge — each source independently sufficient → OR visibility) from derivation (premises jointly required → AND/subset visibility). A merged “user is vegetarian” stated in two spaces would be wrongly hidden from both under a strict subset rule. Fix is a one-bit edge type on provenance; until then, strict subset stays the safe default. (Open Question 4.)

6. Membership Changes: Backfill and Reconciliation

The facade owns all “re-indexing.” Three distinct operations hide under that word:

6.1 Backfill-by-copy (session added to a scope) — in scope, v1

Explicit conclusions are session-local and scope-independent: they already exist in the global (user, user) collection with session_name and message_ids provenance. Backfill is therefore a copy, not a re-derivation:

  1. INSERT ... SELECT the session’s explicit documents into the scope collection (embeddings reused verbatim — no LLM).
  2. Trigger a dream on the scope collection to build/refresh its higher-order layer.

Runs as a queued, idempotent background job (new work-unit type, e.g. scope_backfill:{workspace}:{scope}:{session}) through existing queue infra. addSessions returns a pollable status; the developer never orchestrates anything.

Session-purity invariant (new, required): backfill-by-copy is only sound if explicit-level documents in the global collection remain session-pure — derived from exactly one session, session_name always set. If dreamer consolidation ever merges two explicit documents from different sessions into one (or rewrites an explicit doc using cross-session context), the copy source is already blended and backfill silently leaks across the boundary. Consolidation must either (a) never merge explicit docs across sessions, or (b) demote merged output to a derived level with multi-session provenance, leaving the originals intact. This constraint must be added to dreaming-enhancements.md.

6.2 Reconciliation (session removed from a scope) — in scope, v1

  1. Delete the session’s explicit documents from the scope collection.
  2. Stale-mark derived documents whose source_ids touch them.
  3. Enqueue dreamer reconciliation.

This is the composable-peers Phase 3 staleness machinery in its smallest useful form — shipping it here, confined to one collection, is a de-risked first landing for that machinery.

6.3 Peer cards on membership change — in scope, v1

Peer cards are per-(observer, observed) (stored on the observer peer’s row, keyed {observed}_peer_card), so the (scope, user) collection’s dream produces a scoped card and scoped reads pick it up automatically — no new storage or read plumbing. But cards are written only by the DeductionSpecialist during a dream, as a full-list LLM rewrite with no provenance (update_peer_card takes only content: list[str]), which creates three requirements:

  1. Membership jobs must end with a gate-bypassing dream enqueue. Backfill-by-copy bypasses the deriver, so backfilled documents never tick check_and_schedule_dream’s threshold counter — without an explicit enqueue, a backfilled scope gets no card until ~50 organic new docs accumulate. Reuse the manual-trigger path (enqueue_dream with trigger_reason, bypassing DOCUMENT_THRESHOLD and MIN_HOURS_BETWEEN_DREAMS), debounced so a batch of addSessions calls produces one dream. This turns card freshness after membership changes from “hours (8h min-gap) or never” into “one dream cycle after the job completes.”
  2. Removal reconciliation must rebuild the card from scratch. The normal card rewrite injects the old card into the specialist prompt; entries whose supporting observations were just deleted tend to survive because nothing signals lost support — not fail-closed. For scope-collection reconciliation dreams, withhold the prior card and rebuild solely from observations present in the collection. Scope collections are small; a from-scratch build is cheap and restores the guarantee (the card can only claim what the collection currently supports).
  3. Card-only refresh task (recommended). A lightweight dream variant — DeductionSpecialist with only the card tool, low iteration cap — so membership events and scope cold-starts refresh the card without a full omni dream. Gives the dreamer an event trigger class alongside today’s volume trigger (threshold + idle debounce).

Longer-term corollary (separate spec, peer-card-provenance.md): give card entries internal source_ids (keeping the external list[str] shape). Enables mechanical pruning on removal/erasure instead of the rebuild policy, card invalidation for composable-peers restructuring ops, and card claims as citable evidence in dialectic-enhancements. Scopes v1 does not depend on it.

6.4 True re-derivation — explicitly out of scope

Copy equals re-derive because the deriver writes identical content to every observer. That equivalence is the invariant backfill relies on. The one future feature that breaks it is per-scope custom extraction instructions — that feature must bring a real re-ingestion primitive with it (a useful forcing function: the cost lands with the feature that causes it).

Consequence for DEV-1970’s question: changing a scope’s session set never re-runs the deriver. Adding = row copy + incremental dream. Removing = delete + stale + reconcile. LLM cost only ever touches the higher-order layer (including the card refresh, §6.3), incrementally.

7. Relationship to Composable Peers

The two features answer different questions on different axes:

Composable peers (sub-peers / meta-peers)Scopes
Question answeredWho is this entity made of?What evidence may this read see?
GroupsPeersSessions
Knowledge relationPartition — parts contribute distinct knowledge; parent = union of partsProjection — every scope ⊆ one peer’s whole; contributes nothing upward
Bound atWrite time (which sub-peer a message feeds)Read time (which evidence a query sees)
Changes identity/derivationYes (per-part representation, future per-part instructions)No
Developer grammarA noun you address (honcho.peer("alice-work"))An adverb on a read ({ scope: "therapy" })

Docs rule of thumb: different derived personas → sub-peer; same person through restricted evidence → scope. Sauna’s spaces land unambiguously on scope.

They compose in the direction you’d want — scope modifies any peer read, including composed peers:

await honcho.peer("eng-team").chat(q, { scope: "q3-planning" })
await honcho.peer("alice-work").chat(q, { scope: "client-acme" })

One hard invariant: scope-peers never participate in peer_memberships aggregation. If a scope-peer were a child of the peer it observes, parent aggregation would double-count everything the scope projects. Enforced at membership-create and in aggregation fan-out, keyed off kind: scope.

Both features are peers under the hood; the developer never encounters that overlap because scopes are never addressed as peers through the facade. Composable peers also gets simpler: its §4.6 session-level-filtering goal is subsumed by this spec and can be dropped from that RFC.

8. Enterprise Positioning: Mechanism, Not Policy

Three layers, with a deliberate line:

  1. Visibility partitioning (this RFC) — native. Named scopes for durable, dream-worthy boundaries (departments, clients, matters — tens per peer, not thousands). The dynamic arm for high-cardinality computed access, which is what ABAC policies produce: attributes → session set → { sessions: [...] }. Anti-pattern to document: one named scope per (user × attribute-combination).
  2. Enforcement (follow-on) — native, later. Scope-bound API keys/JWTs: a token minted for workspace + scope(s); the API rejects reads outside them, so omitting the scope stops being an option. The peer-scoped JWT session check in the chat route is the seed. This is what makes “RBAC” honest rather than cooperative filtering.
  3. Principal management — never native to OSS Honcho. Users, roles, grants, SSO/SCIM, audit UIs are an identity-provider product. Honcho verifies token claims naming scopes; how tokens get minted per person/role is the customer’s IdP — or Groudon’s opportunity: “define roles → map to scopes → mint scoped keys” as a managed-platform dashboard feature, a managed-vs-self-hosted differentiator that keeps the OSS core policy-free.

Think Postgres/S3: strong resource primitives and credential scoping, no opinion about your org chart.

9. Schema Changes

v1: none. No new tables, no required migration. The peer row is the scope record; session_peers is the membership table.

Optional hardening, both additive and deferrable:

  1. peers.kind column (promoting the JSONB flag) + partial index — makes list-exclusion and aggregation-exclusion an indexed predicate. Do when scope counts grow or composable-peers aggregation lands.
  2. documents.session_names[] (GIN) — materialized provenance closure, maintained at write time. Belongs to the dynamic arm’s derived-conclusion upgrade (§5.4), not to named scopes.

10. Implementation Phases

Phase 0: Fix the limit_to_session leak (independent bugfix)

Apply session_name to all three RepresentationManager query paths (today: recent only). Ships alone; makes existing behavior honest.

Phase 1: Dynamic session allowlist (crawl)

Wire contract: the existing filters DSL, not a new param (decided 2026-07-07, to avoid any new API contract surface). Dialectic and representation accept filters — the same DSL search and conclusions already take — with a constrained key subset (v1: session_id only; unsupported keys → 422). session.context takes the same allowlist as a repeated sessions query parameter rather than a filters body: the route is a GET and session_id is the only supported key, so a JSON blob in a query string buys nothing (amended 2026-08-19, honcho PR #1030). Bare-list membership sugar makes the common case ergonomic and generic: {"session_id": ["s1", "s2"]}{"session_id": {"in": [...]}} on any regular column (peer_id, etc.); JSONB metadata keeps containment semantics. SDKs expose a sessions: [...] convenience option that compiles to filters: {"session_id": [...]} on the recall endpoints. Enforced uniformly at every conclusion- and message-recall chokepoint; fail-closed derived semantics. This is verbatim what the customer asked for and unblocks them regardless of later phases. metadata was specced here in v1 but never implemented and has been struck (2026-08-19, DEV-2358) — the full filter DSL on the search and conclusions endpoints already covers metadata predicates, and a metadata allowlist would inherit the same explicit-only provenance ceiling as the session allowlist. (The DSL sugar and the Phase 0 fix shipped together: honcho PR #881; the allowlist itself in #882, session.context and the SDK surface in #1030.)

Phase 2: Scope facade (walk)

scopes routes + SDK objects; kind: scope flag + guardrails; { scope } resolution on reads; scopes at session creation; backfill-by-copy and removal reconciliation as queue jobs; peers.list exclusion; dashboard Scopes tab; the guide (Appendix B).

Phase 3: Scoped credentials (run)

JWT claims binding a key to workspace + scope(s); server-side rejection of out-of-scope reads. Groudon key-management surface.

Future (explicitly out of v1)

Per-scope custom extraction instructions + true re-ingestion primitive (must ship together, §6.3). session_names[] provenance closure + merge/derive edge typing for the dynamic arm. Doc-linking/virtual collections if a customer shows up with very many overlapping scopes.

11. Open Questions

  1. Naming. Working name scope (matches the mechanics; “perspective” is taken). space matches sauna’s vocabulary but overloads their domain term and reads product-y; container is generic and evokes storage rather than visibility. Whatever wins must be one word used identically in the query option, routes, SDK, and dashboard tab. Current lean: scope.
  2. Cross-scope dream premises. Should a scope’s dreamer see the global collection’s dreams as premises? Lean no — keep scope collections self-contained (leak-proof by construction); cross-space synthesis lives in the unscoped global query.
  3. Union semantics. scope: ["a", "b"] executes via the dynamic arm (explicit-only). Alternative: query both scope collections and merge (includes both dream layers, but needs cross-collection dedup at read time). v1: dynamic arm; revisit with usage data.
  4. Provenance edge typing. The merge-vs-derive bit on source_ids (§5.4) — needed only for the dynamic arm’s derived-conclusion upgrade. Coordinate with conclusion-tagging.md / reasoning-traces.md.
  5. Backfill job surface. Status polling shape (scope.status()? per-session job records?) and behavior on partial failure.
  6. Scope caps. Do we need per-workspace/per-peer limits on scope count to protect against the cardinality anti-pattern, or is documentation enough?
  7. Complement / rule-based membership. “Everything except HR sessions” is awkward as an allowlist (membership churn on every new session). Options: an exclude_sessions dynamic parameter (precedent: composable-peers v1 had exclude_session_ids), or rule-based scope membership (“all sessions where metadata.x = y”, evaluated at enqueue time). Deferred; see Appendix C.
  8. Dreamer trigger classes & small-collection cadence. Scope collections invalidate the one-big-collection assumptions behind DOCUMENT_THRESHOLD=50 / MIN_HOURS_BETWEEN_DREAMS=8 — small spaces may never organically hit the threshold. §6.3’s event triggers cover membership changes; do we also want per-collection cadence config and/or read-driven freshness (“refresh if stale at read time”)? Broader dreamer-scheduling discussion, not blocking v1.
  9. Peer card provenance (corollary spec). Structured card entries with internal source_ids — replaces §6.3’s rebuild-from-scratch policy with mechanical pruning, and unblocks card invalidation for restructuring ops and card claims as dialectic evidence. Not a v1 dependency.
  10. Allowlist validation & auth (gates Phase 1). Workspace-key requests: take sessions: [...] as-given (trusted caller). Peer-scoped JWTs: validate every entry against session membership, 401 on any miss — never silently drop entries (turns an authz bug into a quality mystery). Cap the list (~1,000, clear 4xx; beyond that, “promote to a named scope”). Message tools use strict intersection: allowlist ∩ observer membership.
  11. Reserved namespace mechanics (gates Phase 2). Verify current peer-name validation permits a prefix user peers cannot already have (else add a collision check at scope-create). kind: scope flag lives in configuration (schema-validated), not internal_metadata.
  12. Backfill idempotency (gates Phase 2). No natural unique key on documents; add→remove→re-add must not duplicate. Stamp copies with copied_from: <source_doc_id> in internal_metadata, dedupe on it; doubles as provenance the removal path uses.
  13. External vector stores (gates Phase 2 — possibly real work). Conclusion queries can route to an external vector store; backfill-copy and removal-delete must propagate there or scoped semantic recall silently misses backfilled docs on those deployments. Audit what the vector-store abstraction exposes for bulk copy/delete.

12. Success Criteria

  1. Sauna implements spaces with: session-creation scopes, chat(q, {scope}), search(q, {scope}) — without ever creating or addressing a peer that isn’t a real user/agent.
  2. A session added to (or removed from) a scope becomes visible (or invisible) with zero deriver LLM calls and dream reconciliation measured in one dream cycle.
  3. Scoped dialectic answers never cite evidence outside the scope (auditable via evidence/provenance once dialectic-enhancements.md lands).
  4. peers.list() output is unchanged for existing consumers after scopes are created.
  5. Deleting the facade routes leaves a functioning observer-peer setup (no facade-only data shapes).
  6. All changes additive; no breaking API changes.

Appendix A: Sauna.ai Mapping

Their mechanics (all via @honcho-ai/sdk) → this design:

Their callCadenceTodayWith scopes
session.addMessages (ingest)per turn, on idleunchangedunchanged; session declares scopes at creation
honcho.searchevery user turn, sub-3sscope via filters: {session_id: {in: [...]}} works today{ scope } sugar resolves the session set server-side
session.context (peerTarget/peerPerspective, cached)per sessionlimit_to_session leaks (Phase 0 fix){ scope } pulls the perspective from the scope collection
peer.chat (“ask about past sessions”)on demandno allowlist — the gap{ scope } (dreams included) or { sessions } (dynamic union)

Interim guidance (before Phase 1 ships): metadata-stamped sessions + filtered search now; if space membership is known at session creation and doesn’t change retroactively, the raw observer-peer pattern works end-to-end today (with the no-backfill caveat).

Appendix B: Guide Outline (“How Scopes Work”)

A single docs page, three diagrams, each paired with the 4-line snippet it explains.

1. What a scope is — projection, not partition:

flowchart LR
    subgraph P["alice — one peer, one global representation"]
        G[(global memory)]
    end
    G -->|projection| T([scope: therapy])
    G -->|projection| W([scope: work])
    T -.->|"⊆ global, plus its own dreams"| G

2. The write path — one extraction, fan-out to collections; scopes never change ingestion:

flowchart TD
    M[message in session s1] --> D[deriver: one LLM extraction]
    D --> GC[(alice global collection)]
    D --> SC1[(therapy collection)]
    D -.->|s1 ∉ work| SC2[(work collection)]
    SC1 --> DR[dreamer per collection → scope-level deductions]

3. The read path — scope resolves server-side; the developer’s grammar never inverts:

flowchart LR
    Q["alice.chat(q, {scope: 'therapy'})"] --> R[facade resolves scope]
    R --> C[(therapy collection: explicit + dreams)]
    R --> S["messages: only therapy's sessions"]
    C --> A[answer]
    S --> A

Page sections: the two arms (named vs sessions: [...]) and when to use which; membership changes (“what happens when I add an old session” — the copy-not-rederive story, one paragraph); the sub-peer vs scope rule of thumb (§7 table, condensed); dropping below the facade (a scope is an observer peer — for power users, with the guardrails listed).

Appendix C: Topology Stress Test — Known Limits

Where scopes are robust: predeclared container boundaries (sauna spaces, clients, projects, teams, visibility tiers like public/private). Where they strain, in rough severity order:

  1. Sub-session granularity. The session is the atomic unit. Message-level classification (per-message PHI/clearance flags), redacting one participant’s disclosures inside a shared session, or “member sees only messages after their join” all cut within a session — no scope shape fixes that. Mitigation is modeling: split sessions along the finest boundary you’ll ever need. Hard floor of the design.
  2. Semantic vs. provenance boundaries. Scopes enforce where something was said, not what it is about. If a sauna user mentions their therapy during a work-space session, that conclusion lives in the work scope. Topic-level privacy (“nothing about my health, wherever mentioned”) needs content classification — conclusion tagging + metadata filters, or future per-scope instructions — not scopes. Docs must say this plainly; it is the most likely silent mismatch between what a developer builds and what their users assume.
  3. Union-heavy grant topologies (Google-Docs-style sharing, per-reader Slack views). When effective visibility = a per-user union of many small, churning grants, named-scope materialization explodes (scope-per-reader ≈ the cardinality anti-pattern), so these route to the dynamic arm — correct but explicit-only. Consequence: the queries such products most want (“what does the brain know that this user may see, including inferences”) get no higher-order layer, because dreams only exist per materialized boundary and scope set-algebra (union/intersection) doesn’t compose dream layers. Partial relief: materialize the stable coarse boundaries (team, folder, org), filter the per-user remainder.
  4. Chat-platform note (Slack/Discord): when the reader is themselves a peer, the existing perspective system is the answer — bob.chat(target: alice) already scopes messages to bob’s memberships and conclusions to (bob, alice). Scopes are for reading contexts that aren’t peers (tiers, channels-as-archives). The real strain there is deriver fan-out cost with observe_others across large channels — orthogonal to scopes, but adjacent enough to confuse; the guide should include a “perspectives vs scopes” decision row.
  5. Complement/denylist boundaries (“everything except HR”) — Open Question 7.
  6. Temporal semantics. Scopes model current access. “Bob keeps knowledge absorbed while he had access” vs. “revocation purges it” is a product decision scopes don’t make (removal purges, per §6.2); point-in-time audit (“what could bob see on March 3?”) isn’t reconstructable because backfill/removal rewrite scope collections. Compliance-grade history would need event-sourced membership — out of scope, noted for enterprise conversations.
  7. Hierarchy/inheritance (folder trees, org charts). Scopes are flat sets; the app maintains the closure (folder → all descendant sessions). Works, but subtree moves are mass membership updates → backfill churn. Native nesting is a possible future composition with peer_memberships — deliberately not v1.