Skip to content

rfc: actor views and subgraph sharing - #951

Open
ragnorc wants to merge 6 commits into
ModernRelay:mainfrom
ragnorc:claude/omnigraph-permissions-sharing-e4c4af
Open

ragnorc wants to merge 6 commits into
ModernRelay:mainfrom
ragnorc:claude/omnigraph-permissions-sharing-e4c4af

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Adds a draft RFC, Actor views and subgraph sharing, for permissions finer than graph × branch × action, and for sharing chosen parts of a graph with external parties.

A company brain built from Slack, email, docs and CRM data has thousands of overlapping audiences. Today an actor can read all of a branch or none of it: docs/user/operations/policy.md says "Per-entity and per-property authorization is not currently supported".

Backing issue / RFC

  • Fixes an accepted issue: Closes #
  • Is an RFC PR, or implements an accepted RFC: docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md
  • Trivial fast-lane

Checklist

  • Change is focused (one logical change): one new draft RFC plus its registry row
  • Tests added/updated for behavior changes (or N/A): N/A, documentation only
  • Public docs updated if user-facing surface changed (or N/A): N/A, no behavior change
  • Reviewed against docs/dev/invariants.md: the RFC's Invariants section maps the design to all 13 invariants and the deny-list

Local verification

  • python3 scripts/check-docs.py (in a venv from scripts/requirements-docs.txt): Documentation OK (249 Markdown files checked)
  • typos docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md docs/rfcs/README.md: no findings
  • Every repository path the RFC cites was checked to exist at 6e31d0ab, and every in-file anchor resolves.

Notes for reviewers

Contract: non-interference. A restricted actor's answer to any query equals the answer on a graph that physically holds only what that actor may see. This covers counts, aggregates, traversal, search ranks and errors. A single visibility door enforces it, and a differential test proves it for every operator.

Model. There are two roles:

  • groups: sets of actors, defined by membership;
  • securables: nodes that hold data, with access defined by edges into them.

The constructs, in field-standard names:

Construct Meaning
@principal The node an authenticated actor is
@group, @member Membership; the only relation that chains through groups
@securable Rows hold data and receive grants
@grant with a role of viewer, editor or owner Union; one hop from the membership closure
@inherits Union over securables (hierarchies, several parents, clearance ladders)
@requires Intersection
@deny Difference; cuts membership, and nothing flows through it
@in Placement on rows, columns, types and edges
@starts / @expires Time windows on edges

Cluster policy version 2 holds only the roots of trust (organization, bypass) and limits. No new Cedar action is needed.

Three properties set the design apart:

  • Derived, not collected. The "Why these constructs" section derives every construct from first principles.
  • Formal semantics. Readability is a least fixed point with a single negation layer, so evaluation terminates and cycles never grant access.
  • Field-standard naming, with the mapping to prior art in Appendix B.

Decisions D1–D8. D8 settles "facts, not queries", and Appendix C compares the model with query-based access, in both expressiveness and efficiency. Data-derived rules enter through four routes:

  • computed placement;
  • @starts;
  • opt-in access edges;
  • definer-rights stored queries.

Scale. Readable sets use a Leopard-style shared closure with incremental maintenance, so a connector sync does not rebuild every actor's view.

Sharing adds no access concept: a recipient is a principal outside the organization group.

Phases. Phases 0–10, each with work items and an exit criterion.

Appendix A records each code and Lance 11 assumption the design relies on, verified against source with a verdict and evidence. The corrections that matter most:

  • @index on a String builds a full-text index, not a BTREE.
  • Unknown annotations are kept as metadata, so the RFC adds a required schema feature, actor-views.
  • Predicate::gq_filters() drops every variant except Gq, and MetadataCount ignores the scan filter.
  • Lance BM25 statistics include filtered and deleted rows.
  • Authorization is keyed by main's manifest pins for the access tables.

Alignment with #884 (polymorphic types). Abstract member sets come from the actor's schema view, which replaces #884's policy_denied_member refusal and policy_filtered marker with view.restricted. #884's Policy section still needs the matching amendment; this is unresolved question 8.

Acceptance is blocked on two measurements (blocked_on):

  • full-text ranking quality with view-independent statistics;
  • the cost of building and incrementally maintaining readable sets at the target scale.

Draft RFC for fine-grained read and write permissions (type, property and
row level) and for sharing parts of a graph with external parties. Defines
the non-interference contract, the schema annotations and policy block,
behaviour for every read and write surface, decisions D1-D5 with evidence,
and implementation phases 0-10. Appendix A records every code and Lance 11
assumption the design relies on, checked at 6e31d0a.
@ragnorc ragnorc changed the title docs(rfcs): actor views and subgraph sharing rfc: actor views and subgraph sharing Oct 12, 2026
Abstract member sets come from the actor's schema view instead of the
polymorphic types draft's policy_denied_member refusal; completeness is
signalled by view.restricted with an opt-in @complete refusal. Security
annotations on interface properties cannot be dropped or added per member,
removing one by any migration route is declassification, @secured_by may name
an interface of securables, and authorization edges may use polymorphic
endpoints.
Collapse the declaration layer into one concept, the scope: seven
annotations (@principal, @scope, @member, @grant, @deny, @Within, @in) plus
@expires, a version-2 policy block holding only unrestricted and limits, and
no new Cedar action. Sensitivity classes become scope rows, the everyone
principal becomes the everyone scope, scope keys share one namespace, fixed
placements become checked references and access data is itself placed.
Decisions are renumbered D1-D7, the rollout is re-cut into phases 0-9, and
Appendix B maps each part of the model to prior art.

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: request changes to the design before acceptance. I am posting a COMMENT because the authenticated account is the PR author. These findings concern proposed behavior; this PR changes documentation only.

Today a reader can see a whole graph branch or none of it. This RFC would let people and agents query only their allowed types, columns, rows and edges. Ordinary graph nodes represent scopes. Memberships and grants build the readable set; denies and containment narrow it. The engine would apply that set to every read and write. A recipient principal would also define a live share or a copied subgraph.

The inline findings identify five contract gaps: principal reassignment, access/data capture order, private data in “public” BM25 statistics, hidden-data dependence in approximate search, and constraint outcomes that disclose more than the stated exceptions. Each comment gives a concrete affected case and the contract that needs to change.

Tradeoffs and long-term cost. Keeping access facts in the existing manifest publication is a sound direction. It avoids a second authorization database and its cross-store consistency protocol. A fixed relation vocabulary and typed view predicate can compose across operators. Cache entries keyed by immutable access pins are also preferable to invalidating on unrelated content writes.

The proposal adds substantial obligations: global identity constraints, policy format 2, actor-aware engine entry points, view-bound plan acceptance, search scorer construction, expiry-aware caches and, later, copy reconciliation. These costs are justified only when one capture and validation contract covers every caller. Five extensions that each add their own search or replay exception would multiply security maintenance. Start with the smallest complete contract: protected identity bindings, coherent captures, fixed safe scoring and exact restricted vector search. That removes the identified leak classes without new authoritative state. It can cost more CPU for large visible sets; the stated measurements must decide whether that cost is acceptable before adding acceleration.

Alignment. I checked the other 29 open PRs for overlap. The material coordination points are #839/#815 (plan acceptance, replay and diagnostics), #792/#793 (scoring and exact/approximate retrieval), and #884 (polymorphic members). Put view requirements into the same accepted-plan boundary. #884's policy amendment is already acknowledged here. Also resolve the scoring contract with #792: it defines statistics over policy-visible entities, while this draft uses public/uniform statistics and compares results with an unrestricted materialized graph. Those definitions do not in general give equal scores or ranks.

I checked current upstream direction as well. Lance #9798 adds search-effort control, and #9847 adds an external indexed input to hybrid FTS. Both are merged upstream, beyond this PR's stock Lance 11 pin. They may reduce future adapter work. Neither establishes the privacy guarantees in this RFC. Keep the pin-specific guards and reassess the adapter at an upgrade.

Validation and limits. Reviewed detached head 5ae580272a54167bc961dd76b23ff50a8224724c in a clean isolated checkout. The diff from merge base 6e31d0ab42c8e2eb6cb38090ab197e1cca7e27df is only the RFC and registry row: 1,570 added lines.

  • /tmp/review-916-docs/bin/python scripts/check-docs.py: passed, 249 Markdown files.
  • bash scripts/check-agents-md.sh: passed, 54 links and 50 docs.
  • typos docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md docs/rfcs/README.md: passed.
  • git diff 6e31d0ab42c8e2eb6cb38090ab197e1cca7e27df...HEAD --check: passed.
  • CI on the exact head is green. The GQT workflow skipped its test steps because no GQT input changed; this is no runtime proof.

The findings use the RFC's set rules and inspection of actual engine capture, mutation, validation and search callers, plus pinned Lance implementation and tests. I read the repository guides and relevant complete upstream storage/search pages. No actor-view runtime exists on either commit, so a before/after GQT or Rust regression cannot prove this proposal yet. No source or test edits were made. Add the small identity, hidden-statistics and constraint cases to the planned actor-capable GQT owner, and the capture interleaving to DST when implementation lands. No scale, recall, ranking-quality, S3/Azure or latency measurements are claimed.

Comment on lines +437 to +439
- These edges, and `@principal` properties, are written only on `main`. A branch
write or a merge that touches them is refused. Without that rule a branch
forked before a revocation would bring the revoked access back when merged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Authorize principal binding changes, not only their branch

The owner rule above applies to access edges, but the only rule for @principal is “main only.” In the sample, Person is unplaced and actor is nullable. A restricted actor with change on main can clear their own binding, then put their actor id on an existing privileged Person. Uniqueness still holds; the next read resolves that Person and inherits its memberships and grants. No access edge changed. The current mutation gate checks the branch action, not authority to assign identity. Define one protected binding lifecycle for create, replace, clear, delete and keyed upsert, enforced in the shared mutation/load validation. Restricting these operations to unrestricted is a small initial rule using an existing primitive. Include this takeover case in the planned actor tests.

Comment on lines +594 to +601
A read captures two things under one schema permit, in this order:

1. ***Authorization state***: the applied policy digest and the `__manifest`
pins on `main` of every ***access table*** (each scope type, each principal
type, each `@member`, `@grant`, `@deny` and `@within` edge type), each pin
being the table's identity, published dataset version, staged version and
transaction id.
2. ***Data snapshot***: the read target, as today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Capture authorization no earlier than the data it protects

The listed order permits: capture access A; commit a revocation; commit new content; capture data B. The request then exposes B using pre-revocation access. The shared schema permit does not exclude these writes: commit_all takes the shared side too. This race exists in the supported single-writer process. A version probe alone does not fix it unless its result replaces or validates the authorization capture after the data cut. Specify one manifest image for both on main, and a capture/revalidation order for branch and historical targets that proves authorization is at least as recent as the protected data. This is the ordering requirement in Zanzibar §2.2. The DST exit case should force this interleaving; testing against the request's already-stale authorization state would miss it.

Comment on lines +759 to +761
- ***Public statistics***: corpus statistics over rows placed in scopes granted
to `*`, built by explicit index reconciliation and stored as derived state
keyed by index identity and the everyone scope's grants.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not classify a wildcard grant as a public statistics corpus

The worked example already supplies a counterexample: space:acme-digest is granted to *, but Alice cannot read it because of @within. Its documents would nevertheless enter this statistics artifact. Changing their terms can change Alice's scores and ranks over unchanged visible rows. Denies, additional placements and delegated caveats cause the same problem. Lance's MemBM25Scorer uses the supplied document frequencies and average length; row prefiltering cannot remove this influence. Also, index identity plus everyone grants does not track a new containment restriction or denial. Use the fixed uniform source initially, or define and prove a statistics population safe for every consuming view, including invalidation on all facts that establish that property. Ranking-quality measurements alone cannot validate this confidentiality condition.

Comment on lines +730 to +735
- **Indexed**: otherwise use the IVF index with probes set up front to about
`k · partitions / (s · N) × 2`, where `s` is the visible fraction.
- **Fallback**: if that probe count exceeds half the partitions, use exact.

The existing overfetch and exact-pass loop
(`crates/omnigraph/src/engine/mod.rs:473-555`) remains the safety net. Whole-table

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep shared ANN state outside the strict non-interference contract

Above the exact threshold, the proposed IVF path can change visible results when only hidden vectors change. Lance trains centroids from dataset rows, selects partitions without the prefilter, then filters candidates within those partitions. Hidden rows can therefore change routing and which visible neighbors are found. Returning k visible rows does not prove the same top-k: Lance stops widening once it has k, and OmniGraph's exact-pass ladder runs for short answers. A full but different answer bypasses both safety nets. This violates the RFC's equality over rows and ranks, even at 95% recall, and propagates into fusion. Use exact search for restricted views until acceleration has a proof under the same observable contract, or explicitly revise the promised disclosure boundary. Add a paired-data acceptance case with identical visible vectors and different hidden vectors; recall against ground truth alone does not test non-interference.

| Edge insert | Both endpoints must be visible; a hidden endpoint fails exactly as a missing one. |
| `load --mode overwrite` | Refused when the actor's view of any type in the batch is restricted. |
| Branch merge | Refused unless every row in the merge diff is writable by the actor. Conflicts are reported without entity ids. |
| Constraint violations | Reported by constraint and property name only. `@unique` omits the other holder's id; `@card` omits the count. |

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Account for the constraint outcome itself as a disclosure

Removing the other holder's id does not close this channel. Give a placed type an opaque slug @key and email @unique. A writer inserts a fresh slug in an editable scope and guesses an email. Success versus @unique failure reveals whether that email exists in a hidden scope, although the guessed slug did not exist in either case. This is outside the stated key-existence exception. Maximum-cardinality checks can similarly reveal hidden edges during insertion, outside the delete exception. The current constraint validator enforces these constraints globally, so redaction cannot solve the general problem. Define the allowed disclosure for every global constraint, or restrict constraints/writes whose integrity check requires hidden facts. Carry that decision through mutation, load and merge, and add paired visible/hidden constraint cases to the planned tests.

Split the scope concept into @group and @securable roles, add @inherits so
audience expressions are closed under union, make denial cut membership
through a denied group, and replace the public * subject with a
policy-defined organization group so recipients never receive company-wide
grants. State the semantics as a least fixed point with one negation layer
and derive every construct. Adopt field names (@securable, @requires, role,
bypass, recipient), add @starts, edge placement, computed placement and
incremental readable-set maintenance, and compare the model with query-based
access in D8 and Appendix C.
…issions-sharing-e4c4af

# Conflicts:
#	docs/rfcs/README.md
The polymorphic types draft now adopts this RFC's rules for abstract types
and stores edge endpoints as packed node references from integer node
identity. Describe that design instead of tag columns, close the cross-RFC
question, and record typed placement values under integer identity as an
open question.
Comment on lines +115 to +119
`materialize(V(a))` is a graph containing exactly the types, properties, rows
and edges `V(a)` admits. The equality covers rows, order where the query orders,
counts, aggregates, search ranks, errors and the result schema. It holds at the
snapshot the read targets, under the authorization state captured for the
request.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] State non-interference so that restricted search can satisfy it

This equation says a restricted result equals a bypass result on materialize(V(a)), and the equality includes search ranks. On that materialized graph, a bypass actor uses index-wide BM25 statistics (line 940). Those are the statistics of the visible rows only. A restricted actor uses uniform or public statistics (lines 919-933). These give different scores, and they can give different ranks.

Example: the query is acme merger, and two visible messages have equal length. Message 1 contains acme three times. Message 2 contains merger two times. In the view, acme is common and merger is rare. View statistics give merger the high IDF, so message 2 ranks first. Uniform statistics give every term the same IDF, so term frequency decides and message 1 ranks first.

Thus Phase 2 ships uniform statistics but cannot pass its own exit gate (lines 1539-1541) or the planned owner (proptest_equivalence, line 1394). Indexed nearest has the same problem: on the bypass side, an IVF index trained on the materialized rows gives different approximate neighbours.

The security property you need is narrower: the answer depends only on the view. Please state it for a pair of graphs: if V_G(a) = V_G'(a), then answer(q, G, a) = answer(q, G', a). Keep the materialized equation as the oracle for operators whose result is a function of rows only (scans, aggregates, traversals, exact nearest). For bm25, indexed nearest and rrf, define restricted scoring as a declared function of the visible rows and fixed constants. The alternative is view statistics. They make the equation exact and match the "policy-visible entities" rule in #792, but their per-query cost needs a measurement.

In both cases, the differential test must build pairs of graphs that differ only in hidden rows. That pair test is also the acceptance case that the open ANN thread asks for.

Comment on lines +933 to +936
fallback when the artifact is missing or stale. The choice is gated on ranking
measurements (`blocked_on`). The flat path adds its own filtered input rows to a
supplied scorer (`lance-index-11.0.0/src/scalar/inverted/flat_search.rs:548-559`);
because the engine filters by expression, those rows are visible rows, which

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Re-anchor restricted full-text search on main's BM25 eligibility rule (#839)

Two facts on main (0b5e17fa) change this section.

  1. The view conjunct would run after scoring. feat(planner): accept every read plan against its checked query (RFC 0047) #839 merged after this head. Its planner puts a BM25 scan's whole eligibility (pushed filter, gate set, search members) after scoring when full-text coverage is partial (optimizer.rs:2589-2603). The scan then sets prefilter(false) (scan.rs:268). This RFC makes the view conjunct an ordinary pushed filter. On a table with an uncovered tail, Lance would then score with whole-corpus statistics, hidden rows included, and drop hidden rows only afterwards. That is the leak this section exists to close. The statement at lines 872-875 ("prefilter(true) on every scan with a filter") is no longer true.

  2. Restricted scores would depend on index coverage. The flat path adds its counted input to any supplied scorer (initialize_scorer, lance-index-11.0.0/src/scalar/inverted/index/flat_search.rs:539-574; the cited path inverted/flat_search.rs does not exist). An indexed segment with a base scorer uses that scorer only. So a visible row gets uniform statistics when its fragment is indexed, and uniform plus visible-tail statistics when it is not. An optimize run that changes no data can then change restricted scores and ranks. That violates invariant 7 ("missing coverage may change cost but not correctness"). It is the same defect class that feat(planner): accept every read plan against its checked query (RFC 0047) #839 fixed for unrestricted reads. The main guard fts_prefilter_changes_unindexed_scores_and_postfilter_keeps_them pins the Lance fact: a prefilter changes an uncovered row's score, and an indexed row always scores from the index's statistics. Upstream Lance main (v14 betas) has the same initialize_scorer, and with_shared_scorer is still pub(crate), so a Lance bump does not remove this.

Please:

  • give the view conjunct its own eligibility: always before scoring, never moved by the user-filter rule, and checked at plan acceptance;
  • specify a restricted scoring path on Lance 11 whose scores depend neither on coverage nor on hidden rows, and add a surface guard for it to the table at line 1172.

A refusal based on coverage is not a valid fix. The deny-list forbids a logical precondition on physical index coverage.

Comment on lines +925 to +927

- ***Public statistics***: corpus statistics over rows placed in securables
granted to the organization group, built by explicit index reconciliation and

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Public statistics now include rows that recipients and other restricted actors cannot read

The earlier thread on wildcard statistics still applies. The change from * to the organization group adds two direct counterexamples from this RFC:

  1. Recipients. A recipient is never in the organization group (line 572). But a recipient is a restricted actor, so it gets public statistics (line 919). Those statistics come from exactly the rows that recipients must not see. A change to the terms in channel:general then changes the scores and ranks that client:globex gets over its unchanged folder:globex-shared rows. The rows stay hidden, but information about them flows to the recipient.
  2. Embargo. Lines 526-528 put an embargoed release in a securable that is granted to the organization with @starts. Before the start time, no actor can read it. But the artifact is "keyed by index identity and that group's grants", without time. So the release text enters every restricted actor's statistics before the release.

A deny on an organization-granted securable, an additional placement, @requires and a caveat give the same result. A grant from the organization group does not prove that a row is in every view that consumes the statistics.

Please make the design statistics a function of the consuming view only (see the comment on the contract at line 119). Remove public statistics, or define a population that is provably inside every consuming view. Its invalidation must cover every fact that establishes that property, time windows included. Ranking measurements (blocked_on) cannot validate this confidentiality condition.

Comment on lines +255 to +259
- **The organization group.** A `@group` row keyed `organization` is the
***organization group***. Its members are implicit: every actor in the cluster
policy groups listed under `views.organization`. A grant from it is a
company-wide grant. Recipients and other outside principals are never in
those policy groups, so no company-wide grant reaches them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Make the organization group's membership come only from policy

Line 678 and the Authority table make the organization group a root of trust that policy defines. The rules do not enforce that:

  • Step 1 of Computing access (lines 303-304) follows every live @member edge from p. Nothing refuses an edge client:globex -member→ organization, or a member edge into organization from a group that contains a recipient. With such an edge, "no company-wide grant reaches them" (lines 258-259) is false, and so is the Phase 1 exit criterion (line 1517).
  • The row is found by the key organization. The RFC does not say which @group type holds it, who may create or delete it, or what happens when two group types each hold that key. Only securable keys share one namespace (line 249).
  • Suppose the row is in a type that is also a securable, such as Team. Its creator writes the owner grant (line 477). The creator can then write @member edges into it (line 471) and @deny edges on it. A deny on it removes the denied subjects from the organization (step 3 never enters a group in D), and thus from every company-wide grant.

Please make this structural:

  • the organization group has no explicit incoming @member edges and is never a @deny target, and write validation refuses both;
  • its key is reserved across every @group type;
  • only bypass actors create or delete it.

Add a case with a recipient that has an explicit member edge into the organization to the generated access graphs (line 1418).

Comment on lines +474 to +479
- Access edges and `@principal` properties are written only on `main`. A branch
write or a merge that touches them is refused. Without that rule a branch
forked before a revocation would bring the revoked access back when merged.
- A new securable row must be written together with an `owner` grant into it
from a subject in the writer's membership closure; otherwise the write is
refused with `securable_owner_required`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Protect securable keys and actor ids as identity, not as free text

The earlier principal-binding thread is still open. This rule still controls only the branch of a @principal write. It does not say who may assign, move or clear an actor id. The new model adds the same gap for securables. A placement names a securable by a string key. This rule lets any writer with change on main create any securable key that does not exist yet, and become its owner.

  • Squatting. A writer creates Channel { slug: "slack:C777" } with an owner grant before the connector mirrors that private channel. The bypass connector's keyed insert then upserts the row. The writer's owner grant stays, so the writer reads every message that the connector puts there. A non-bypass connector gets key_conflict instead and cannot mirror the channel.
  • Reuse. Delete of a securable is refused only when a fixed @in names it (lines 266-268). Delete of an unplaced securable such as Person needs only change. Rows placed per row keep the deleted key, and whoever creates that key next gets those rows. Example: a Person personal securable keyed by email is deleted at offboarding. A new hire later gets the same email. Through role(s) ≥ viewer if s ∈ C, the new hire reads the private rows of the earlier person.

This is the rule of invariant 6 ("never infer identity from an alias, path, … or name"), applied to access identity. Please define one protected lifecycle for every identity binding (actor id, securable key, organization row), enforced in the shared mutation, load and merge validation. For example:

  • refuse a restricted writer's create of a securable whose key a placement already names;
  • refuse delete of a securable while a placement names it, and define who may delete an unplaced securable.

The per-row @in BTREE makes each check one IsIn count per placed type.

Unresolved question 8 then is also a security question, not only a cost question. Packed references from the integer node identity draft do not appear to reuse an id (that draft refuses an id below the reserved frontier), so they would remove the reuse case.

Comment on lines +489 to +491
narrow: a list of securables, a list of types, an action subset and an expiry.
A caveat's securable list covers the listed securables and every securable that
inherits from them. The effective readable set is

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, optional] Caveat coverage through inheritance also covers derived content from other sources

digest:acme inherits channel:deal-acme and requires channel:general. A caveat that lists only channel:deal-acme covers digest:acme by this rule. So an agent that is limited to the deal room reads summaries that also derive from channel:general. The result stays inside R(user) ∩ R(agent), so nothing goes past the user's own view. But it is wider than the caveat says. Consider covering an inheritor only when every one of its @requires targets is covered too, or state the current rule as intended.

Comment on lines +846 to +861
**Coverage validator.** A new check runs in `engine::execute`, beside
`validate_aggregate_specs` and `validate_output_schemas`, so every door including
replay passes it (`crates/omnigraph/src/engine/mod.rs:409-418`). It refuses a
plan that:

- scans a restricted placed type without its view conjuncts;
- uses `MetadataCount` on a restricted type (its lowering ignores the scan filter,
`crates/omnigraph/src/engine/lower.rs:473-487`);
- uses the CSR, the `count`/`not` degree shortcut
(`crates/omnigraph/src/engine/lower.rs:230-277`) or the CSR-derived search gates on
a restricted type;
- expands into a restricted type, or over a placed edge type, without a mask.

The bound plan's assumptions gain the view digest. Replay recomputes the view
for the replaying actor and refuses if the digest differs, so a saved plan
cannot be replayed by another actor or after a revocation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Put the view requirement into the plan-acceptance door that is now on main

#839 merged RFC 0047 plan acceptance. accept_query and accept_replay are now the one door from a plan to execution (validate/mod.rs, replay.rs:178-215). Replay compares catalog_digest, which is the schema_ir_hash of the bound schema (replay.rs:220-225). A second check in engine::execute plus a separate view digest in plan assumptions would make two acceptance doors that can drift.

Two details also need a decision:

  1. A view catalog that keeps CatalogIdentity::Bound(schema) produces the full schema hash. The IncompatibleFacts refusal prints both hashes. A change to a hidden type then becomes visible through replay. The Surfaces table does not list replay envelopes or replay refusals.
  2. The view digest is per actor. So it can be the identity of the view catalog itself, and the existing replay comparison then refuses another actor's plan with no new mechanism.

Please express the coverage rules as acceptance Requirements, and make the view digest part of the catalog identity that accept_replay checks.

Comment on lines +1080 to +1087
**Placement on interfaces.** Under that RFC, property annotations on an
interface property apply to every implementor that inherits it. A fixed
`@in("key")` on an interface property follows that rule with two additions: a
member that redeclares the property may not drop it (a refusal, not that RFC's
lint warning, because dropping it would expose the column), and a member may not
add a fixed `@in` to an inherited property, so the property's visibility is the
same for every member. Type-level `@in` belongs to concrete types and is not
inherited through `implements`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3, optional] Two statements about #884 are now stale

  • rfc: polymorphic types with interfaces, unions and polymorphic edges #884 at d9caeb69 refuses a redeclaration that drops an inherited placement itself, and it refuses a member that adds a fixed placement to an inherited property (its 2026-10-07-polymorphic-types.md, Interfaces). "A refusal, not that RFC's lint warning" no longer describes it.
  • rfc: polymorphic types with interfaces, unions and polymorphic edges #884's Policy section says an interface-wide mutation "checks every arm against the actor's view before staging". This RFC checks writes in end-of-attempt validation and again in the commit_all revalidation (lines 972-982). Please align the two texts on one enforcement point. The later point is the correct one, because it sees cascades and the committed before-image.

@cursor

cursor Bot commented Oct 17, 2026

Copy link
Copy Markdown

Review of 44daa2c2: request changes before acceptance

Recommendation: request changes. The direction is sound. Two problems remain before this design can be accepted. First, five new contract gaps: two in the access model and three in restricted search. Second, the five findings from the review at 5ae58027 are still open. This PR changes documentation only; the findings concern the proposed behavior.

What this RFC does

Why: Today an actor reads a whole graph branch or none of it. A company brain needs per-channel, per-folder, per-column and per-row audiences, plus walls and external shares.
What: It adds an actor view, built from graph data with a fixed vocabulary: groups, securables, @member, @grant, @inherits, @requires, @deny, time windows, placement with @in, a policy-defined organization group and a bypass role.
How: One capture of the authorization state per request, a per-actor readable set over a shared closure, a typed ViewSet IR leaf on every scan, masked traversal, view-independent search statistics and write checks in attempt validation.
Impact: It touches schema, policy format 2, every read door, planner acceptance, search, writes, history, the change feed and sharing. It also adds new derived state (closure, view cache, statistics).

What changed since 5ae58027

The revision splits scopes into @group and @securable. It adds @inherits and @starts, and denial now cuts membership. It replaces * with an organization group defined by policy, and it adds computed placement, incremental maintenance (Phase 4), D8 and Appendix C. It also aligns with #884: member sets now come from the schema view, and policy_denied_member and policy_filtered are withdrawn. The least-fixed-point semantics with one negation layer is a clear improvement. It is deterministic, cycle-safe and easy to analyze. I checked the worked example table against the rules, and it is correct.

Earlier findings: status at 44daa2c2

None of the five threads has a reply. The text that each thread cites is unchanged or carries the same rule:

  • Principal binding: open. Lines 474-476 still control only the branch. The new model also lets the bound principal read its own personal securable, so a takeover gains that too. The new securable-key comment extends this thread.
  • Capture order: open. Lines 736-756 still capture authorization first and data second, and they still claim "never older than the data it filters". commit_all still takes only the shared schema permit (staging.rs:941-946).
  • Wildcard statistics: open, and wider now. See the new public-statistics comment.
  • ANN non-interference: open. The indexed IVF path and the recall-only acceptance (lines 1428-1431) are unchanged.
  • Constraint outcomes: open. The new global uniqueness of securable keys adds one more instance. A restricted writer who creates a securable learns whether that key exists in any hidden securable of any type. That is outside the "keyed type" exception at line 156.

Blocking findings (new)

  1. Non-interference cannot hold for restricted search as stated. The materialized-bypass equation and uniform statistics give different ranks. Phase 2 would fail its own exit gate. State the contract over pairs of graphs with equal views.
  2. Main's BM25 eligibility rule (#839). On partial coverage, the view conjunct would run after scoring, which leaks hidden statistics. Under Lance 11's flat scorer, restricted scores would also depend on index coverage, which violates invariant 7.
  3. Public statistics. They are built from organization rows that recipients cannot read, and from embargoed @starts rows before release.
  4. Organization group membership. An explicit @member edge into organization gives a recipient every company-wide grant. The row's identity and lifecycle are also undefined.
  5. Securable keys and actor ids as identity. Any change writer can squat a key before the connector mirrors it. A deleted key's rows go to whoever creates the key next.

Non-blocking findings

Tradeoffs and long-term cost

The core choices remain good. Access facts live in the graph and publish through __manifest, with no second authorization store and no dual write. The fixed vocabulary keeps the readable set computable without a solver. Views are keyed by access version rather than by main's head. The two-layer closure with incremental maintenance is an idempotent reconciler from accepted state, consistent with the deny-list.

This revision adds three kinds of root of trust held in graph data: actor ids, securable keys and the organization row. Each one has its own ad-hoc rule today. After five more constructs of this kind, the RFC would have five lifecycle exceptions, each a possible leak. One protected identity-binding lifecycle in the shared write validation removes that class. It adds no new state: it uses the BTREE that each per-row @in already gets.

Search is the other area of cost. Today the design carries a statistics artifact, a fallback, an engine-built FTS plan and a probe formula. That is four mechanisms for one contract that is not yet satisfiable. A contract over pairs of graphs, a scoring rule that depends only on the view and is independent of coverage, and exact search for restricted views would give one smaller obligation that the planned differential test can check. The cost is more CPU for large visible sets, which the blocked_on measurements must quantify. Computed placement and Phase 4 maintenance are reasonable later phases, but they add an expression evaluator to the write path and a change-feed dependency. Keep them out of the first accepted scope unless a design partner needs them.

Alignment

Validation and limits

  • Detached worktree at 44daa2c239c8d84d0f9587f15b691049769ddc0d. Merge base 2d8bab7a. The diff from the merge base is the RFC (+1958) and one registry row (+1). git diff 5ae58027 44daa2c2 changes the RFC by +810/−420.
  • python3 scripts/check-docs.py (pinned requirements-docs.txt): "Documentation OK (262 Markdown files checked)".
  • bash scripts/check-agents-md.sh: OK (54 links, 50 docs).
  • typos 1.50.1 (the CI pin) on the RFC and docs/rfcs/README.md: passed. git diff --check 2d8bab7a...HEAD: passed.
  • On main 0b5e17fa: cargo test -p omnigraph-engine --locked --test lance_surface_guards fts_prefilter_changes_unindexed_scores_and_postfilter_keeps_them -- --exact: 1 passed, 0 failed. This confirms the Lance 11 fact behind the eligibility finding: a prefilter changes an uncovered row's BM25 score, and an indexed row scores from the index's statistics either way.
  • CI on the exact head: CI, GQ Logic Tests and DST passed. The Rust test and lint jobs were skipped because only docs changed, so CI gives no runtime proof.
  • The other findings come from the RFC's own rules, code inspection at the head and on main, and the pinned Lance 11.0.0 sources (flat_search.rs:539-574, fts.rs, scanner.rs). The upstream FTS format page does not document how filtering interacts with scoring statistics, so the code and the guard are the evidence.
  • No actor-view runtime exists, so no regression can fail before and pass after for this PR. I made no source or test edits. When implementation starts, put these cases in the planned actor-capable GQT section: the organization-membership edge, key squatting and reuse, and paired hidden-row graphs for bm25 and nearest. Put the coverage-independence case in lance_surface_guards.rs. I make no claims about scale, recall, ranking quality, latency, S3 or Azure.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants