Skip to content

rfc: granular permissioning - #951

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

ragnorc wants to merge 11 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 (333 Markdown files checked)
  • typos docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md docs/rfcs/README.md: no findings
  • Every OmniGraph file and symbol the RFC cites was checked at 9575b044 (the 2026-10-20 additions also at 7fd9cc90), and every Lance line anchor in lance-11.0.0 / lance-index-11.0.0.

Notes for reviewers

Scope of acceptance. The contract, the model and Phases 0–3 (hardening, the access model with type and column placement, row reads, row writes). Phases 4–11 (agents, incremental maintenance, packed placement references, data-derived and edge placement, search acceleration, view-filtered history and feeds, sharing, vaults) are designed to show the model extends, and each is accepted by amendment with its evidence.

Contract: non-interference over pairs. Two graphs that give an actor the same view give it the same answer to every query: rows, counts, aggregates, traversals, search scores and ranks, errors and the result schema. The materialized-view equation is the test oracle, evaluated with the same evaluators. Enforcement sits at plan acceptance for reads and at attempt validation plus the publication compare-and-swap for writes.

Model. Groups (sets of actors) and securables (nodes data is placed in), with a fixed vocabulary:

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
@requires Intersection
@deny Difference; cuts membership, and nothing flows through it
@in Placement on rows, columns and types
@starts / @expires Time windows on edges

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

Revision of 2026-10-20 (review of 2026-10-18):

  • Branch writes during a revocation. A branch write is checked at its attempt's captured authorization state. The one-attempt window in which a revocation on main misses it is stated in the contract, What is not promised and the Phase 3 exit. No compare-and-swap spans two branch chains, and a process-local gate fences nothing across processes.
  • Securables are never deleted in the first scope, and securable, group and access rows are written only on main, so no key ever names a second securable on any branch or snapshot. Keys are immutable source ids; deletion returns with packed references.
  • The disclosure table is complete for write checks:
    • the @card minimum and branch merge are added;
    • placed columns are nullable;
    • a restricted merge with hidden changes is refused without a conflict payload;
    • schema apply and maintenance need a bypass actor.
  • Restricted bm25 statistics cover visible rows with nonempty analyzed text, unaffected by the query's filters (rfc: analyzed lexical search on the shared expression model #792's population).
  • Aligned with the graph access RFC as revised on 2026-10-20: one cluster-backed serving model, Omnigraph-If-Actor, the disposition object.

Revision of 2026-10-17:

  • Restricted search is exact and scored from the view (D10). bm25 runs Lance's public FlatMatchQueryExec over the visible rows with a zero base scorer, so statistics come only from visible rows and neither hidden rows nor index coverage nor stale postings change a score. Restricted nearest is exact, because IVF partition choice ignores the prefilter. The earlier public and uniform statistics are withdrawn: one leaked, the other diverged from the oracle.
  • The view conjunct has its own eligibility, always before scoring; on main, partly indexed bm25 moves user filters after scoring.
  • Plan acceptance is the one door. View requirements join Requirements::check, and the view digest joins catalog_digest, so replay of another actor's plan is refused without a new mechanism. Replay refusals are redacted.
  • Capture order. Authorization is never older than the data: one manifest image on main; data then authorization elsewhere; access pins as compare-and-swap expectations for writes.
  • Identity bindings (D9). Actor ids, securable keys and the internal group share one protected lifecycle, which closes identity takeover, key squatting and key reuse.
  • The internal group replaces "organization", whose members in the managed control plane include outside collaborators.
  • Constraint disclosures are stated exhaustively.
  • Caveats cover an inheritor only when every @requires target is covered.
  • Every surface is covered: direct and served access (rfc: graph access and cross-process writes #1017), MCP as mounted today, unpublished commits and unattached snapshots, and the shared error registry.

Alignment with open work.

Blocked on two measurements: exact restricted search costs at target sizes, and readable-set construction at 10^5 principals and 10^6 securables.

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.

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.

Addressed in 7f3d601 and 90f3ec0. A @principal value is now an identity binding (Identity bindings, D9). Assigning, changing or clearing one is a bypass-only write on main. Every other actor is refused with identity_binding_denied in the shared attempt validation of mutation, load and merge. Evidence includes this takeover case: a restricted writer moving its actor id onto a privileged principal.

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.

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.

Addressed in 7f3d601 (Capturing a view).

  • Reads on main take authorization and data from one __manifest image.
  • Branch and historical reads capture the data first and main's authorization after it, under one schema permit.
  • Writes on main make the access pins expected versions of the publication's compare-and-swap.

90f3ec0 states the one remaining window, a branch write racing a revocation on main, in the contract, What is not promised and the Phase 3 exit. Evidence has a DST schedule for the read race you describe.

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.

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.

Addressed in 7f3d601: public and uniform statistics are withdrawn (D10). Restricted bm25 runs Lance's flat operator over exactly the visible rows with a zero base scorer. The statistics are therefore those of the actor's view: visible rows with nonempty analyzed text (90f3ec0). A grant, deny, placement or caveat elsewhere cannot change a restricted score.

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.

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.

Addressed in 7f3d601: restricted nearest on a placed type is always exact. It runs use_index(false) with the view conjunct prefiltered, is bounded by max_exact_search_rows and is refused above that bound. Restricted actors get no IVF path. Accelerated restricted search is Phase 8, and it returns only with a proof under the pair contract.

| 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.

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.

Addressed in 7f3d601 and 90f3ec0. Constraint disclosures now lists every write check whose outcome can depend on hidden rows or hidden schema, with what it may reveal and what is withheld:

  • @key;
  • @unique, which covers your opaque-slug and guessed-email case;
  • the @card maximum and minimum;
  • cascade on delete;
  • branch merge.

The lint flags human-meaningful keys and @unique properties. Uniqueness scoped to a placement, and cardinality over visible edges, are open questions.

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.

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.

Addressed in 7f3d601. The contract is now stated over pairs of graphs with equal views. The materialized equation is the test oracle, evaluated with the same evaluators. Restricted search scores from view statistics (D10), so both forms hold for every operator offered to restricted actors.

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.

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.

Addressed in 7f3d601. The view conjunct has its own eligibility, always before scoring, independent of the user's filter eligibility (Scans, Plan acceptance). Restricted bm25 never reaches the prefilter(!(filter_after_scoring && ...)) path. It runs the flat operator over the visible rows, with the user's eligibility after scoring. No postings are read, so index coverage cannot change a restricted score. Appendix A records the AfterScoring fact on main.

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.

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.

Addressed in 7f3d601: public statistics are withdrawn (D10), for exactly these two reasons, recipients and embargoed rows. Restricted bm25 now scores from the actor's own visible rows only. See the reply on the earlier statistics thread.

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).

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.

Addressed in 7f3d601 and 90f3ec0.

  • The group is now the internal group, keyed internal, and the key is reserved across every @group type.
  • Its members come only from views.internal.
  • Write validation refuses an explicit @member edge into it and a @deny onto it.
  • Only bypass actors create it, on main, and it is never deleted.

The rename also keeps it apart from the managed control plane's organization, whose members include invited outside collaborators.

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.

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.

Addressed in 7f3d601 and 90f3ec0. Actor ids, securable keys and the internal group share one protected lifecycle (Identity bindings, D9):

  • Only bypass actors create securables or assign actor ids, and only on main.
  • Keys never change.
  • In the first scope securables are never deleted, so no key ever names a second securable.

Squatting, takeover and reuse are all Evidence cases. Restricted creation and deletion return with packed references (Phase 6).

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.

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.

Taken in 7f3d601: a caveat covers an inheritor only when every one of its @requires targets is covered too. Evidence has the case.

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.

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.

Addressed in 7f3d601 (Plan acceptance).

  • AcceptInput carries the view spec, and view requirements are checked in Requirements::check.
  • The view digest joins catalog_digest, recomputed from the replaying request's own view. accept_replay therefore refuses another actor's plan, or a plan whose view changed, with no new mechanism.
  • For restricted actors, refusals from accept_replay, plan_pins_snapshot and replayed_coverage_holds are redacted.

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.

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.

Both fixed. This RFC now describes #884's own refusals (Placement on interfaces). #884's Policy section names attempt validation and the commit revalidation as of 24b60eb.

@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.

…issions-sharing-e4c4af

# Conflicts:
#	docs/rfcs/README.md
Address the review findings on the actor views RFC, align with main at
9575b04 and with the open drafts that touch access.

- State non-interference over pairs of graphs with equal views; keep the
  materialized equation as the test oracle under identical evaluators.
- Make restricted search exact and scored from the view: bm25 through
  Lance's flat operator over the visible rows with a zero base scorer,
  nearest through the exact route. Withdraw public and uniform statistics.
- Give the view conjunct its own eligibility, always before scoring, and
  move view requirements and the view digest into plan acceptance.
- Capture authorization no older than the data; make access pins
  compare-and-swap expectations for writes.
- Protect identity bindings (actor ids, securable keys, the internal
  group) with one lifecycle; rename the company-wide group to the
  internal group.
- State constraint disclosures exhaustively, fix caveat coverage, and
  cover direct and served access, MCP, unpublished commits, unattached
  snapshots, the error registry and every surface.
- Ask acceptance for Phases 0-3 and re-cut the later phases.
- Cite code by file and symbol at 9575b04.
@cursor

cursor Bot commented Oct 18, 2026

Copy link
Copy Markdown

Review of 7f3d601e: request changes before acceptance

Recommendation: request changes. The direction is sound, and this revision closes most of the earlier contract gaps. Four gaps remain in the text that Phases 0–3 ask to accept. This pull request changes documentation only. The findings are about the proposed behavior.

What this RFC does

Why. Today an actor reads a whole graph branch or none of it. A shared company graph needs per-channel, per-folder, per-column and per-row audiences, plus walls and external shares.

What. An actor view, built from graph rows with a fixed vocabulary: groups, securables, membership, grants, inheritance, requirements, denial, time windows and placement. The internal group and the bypass role come from cluster policy.

How. One capture per request. A readable set as a least fixed point with one negation layer. Exact restricted search scored from the visible rows. One protected lifecycle for identity bindings. View requirements and a view digest inside the existing plan-acceptance door.

Impact. Accepting Phases 0–3 adds policy format 2, actor-taking read doors, view-bound plan acceptance, a protected identity lifecycle, exact-search admission and redaction on every restricted surface. Later phases add maintenance, history, shares and acceleration, each by amendment.

Earlier findings

From the review at 5ae58027:

  • Principal binding: fixed. Assign, change and clear of @principal are bypass-only, in the shared validation of mutation, load and merge (lines 351 and 989–990). A restricted writer cannot move an actor id onto another person.
  • Capture order: partly fixed. Reads on main use one __manifest image. Other reads capture data first and main's authorization after it. Writes on main put the access pins into the publication compare-and-swap. A write on another branch can still miss a revocation for one attempt, and that window disagrees with the write contract and the Phase 3 exit. See blocking finding 2.
  • Wildcard statistics: fixed. Public and uniform statistics are withdrawn (lines 1126–1131).
  • Approximate nearest: fixed for the accepted scope. Restricted nearest is exact: use_index(false), the view conjunct prefiltered, ties by id. The cited IVF partition choice (lance-11.0.0 ivf/v2.rs:1459-1469) and the stop at k (knn.rs:1725-1728) are why approximate search stays out. A paired hidden-vector guard is specified.
  • Constraint outcomes: partly fixed. The disclosure table covers @key, @unique (including interface-wide), an upper reading of @card, cascade-on-delete and securable creation. Two further outcomes still depend on hidden state. See blocking finding 3.

From the review at 44daa2c2:

  • Pair contract versus search statistics: fixed. The contract is the pair implication. The materialized equation is the oracle when both sides use the same evaluators. With D10, restricted bm25 and nearest are functions of the view, so both forms hold in the first scope. I checked the Lance 11 flat path: initialize_scorer adds the counted input to a zero base (lance-index-11.0.0 flat_search.rs:539-587), and FlatMatchQueryExec does not merge index hits into that score.
  • BM25 eligibility on partial coverage: fixed in the design. The view conjunct has its own eligibility, always before scoring, and plan acceptance requires it. Restricted bm25 is a flat operator over an already filtered read, and the user's filters run after scoring. Coverage is not a precondition. On this tree the rule is still Eligibility::AfterScoring for partial or absent coverage (crates/omnigraph-planner/src/optimizer.rs:2684-2697) and scan.rs:268 still turns prefilter off in that case. The RFC routes restricted bm25 around that path. See the optional note on what the flat operator still opens.
  • Public statistics and embargo: fixed by removing the artifact. An embargoed grant is absent from the view before @starts.
  • Organization group: fixed. The row is the internal group. Membership comes from views.internal. Write validation refuses an explicit @member into it and a @deny onto it. The key is reserved. Only bypass actors create or delete the row.
  • Securable keys and actor ids: partly fixed. Creation is bypass-only, so a writer cannot squat a key. A key change is refused for everyone. Delete is refused while a placement on the write's committed state names the key. A recreated string can still meet rows on another live branch. See blocking finding 1.

Optional items from 44daa2c2:

  • Plan acceptance: fixed in this RFC. View requirements join Requirements::check. The view digest joins catalog_digest. Replay recomputes the digest from the replaying request. Refusals from accept_replay, plan_pins_snapshot and replayed_coverage_holds are redacted. Current accept_replay still prints both schema hashes (crates/omnigraph-planner/src/validate/replay.rs:184-189). That is the behavior this RFC says to change.
  • Caveat coverage through @inherits: fixed (lines 616–619). An inheritor is covered only when every @requires target is covered.
  • Statements about #884: partly fixed, and one new sentence is false. Placement removal is described as a refusal, and the write point is attempt validation plus commit revalidation. See blocking finding 4.
  • Code anchors: fixed. Appendix A was rechecked against this head, which matches main at 9575b044 outside the RFC, and against Lance 11.0.0. The load-bearing anchors I opened match the text, with the flat-path qualifications below.

Blocking findings

  1. Identity is checked on one committed state. Line 360 says a recreated key inherits no rows. The check behind that sentence reads the committed state of the write (lines 983–995): one IsIn on each per-row placement BTREE, plus each fixed @in. Placement values also live on branches (line 884). Ordinary branch reads are in Phases 0–3 and follow the branch's placements (lines 681–682). Securable rows are outside the main-only list, which covers access edges and @principal (lines 575–576). A bypass offboarding can clear the placements on main, delete the securable and create a new securable with the same string. IsIn on main sees no placement. A branch forked earlier still stores rows whose @in is that string, and a reader of the branch resolves the string through the new securable. The same class is every string placement that outlives the securable row: another live branch in the first scope, and any snapshot a later publish or a Phase 9 read can still open. Invariant 6 says a dropped identity is not the next row that reuses the name. D9 adopts that rule for securable keys. The check belongs at the identity lifecycle and has to cover every snapshot the accepted scope can read. The inline comment states the required text.

  2. The branch-write window contradicts the write contract. Lines 149–152 say a write succeeds only when every touched row is writable in the actor's view. Lines 927–932 say a branch write keeps the authorization captured for the attempt, so a revocation on main during that attempt can miss the attempt. Lines 1869–1872 say a write that races an access change in another process retries or refuses. "What is not promised" does not list the window. commit_all takes the schema permit shared, then the attempt branch's gate (crates/omnigraph/src/exec/staging.rs:973-979). The comment at staging.rs:942-943 says the manifest compare-and-swap is the foreign-process authority. Appendix A records the same fact (line 2038). A per-branch gate does not serialize a main revocation against a feature-branch commit. ExactGraphHead fences a publish on main because an access change moves main's head. It does not fence another branch's chain. This is invariant 3: one attempt uses one coherent authority, and a retry starts fresh. The class is every publication whose chain is not main. The contract, the capture section and the Phase 3 exit have to state one rule. The inline comment gives the two rules that each close the class.

  3. The disclosure list leaves two channels out. Lines 186–191 and 585–587 say a restricted writer learns exactly the facts in the table. Two further outcomes depend on hidden state. @card reports the maximum and then the minimum (crates/omnigraph/src/validate/kernels.rs:494-515), and the message includes the source id and the count (kernels.rs:578). Deleting the last visible edge fails when the global count would fall below min, and succeeds when hidden edges keep the count at or above min. Success versus failure reveals hidden edges. "Reached the bound" describes the maximum. A fixed @in on a non-null property removes that property from the schema view, so a restricted insert cannot set it. resolve_assignments refuses a null on a non-nullable property before any batch and names the property (crates/omnigraph/src/exec/mutation.rs:19 and mutation.rs:32-36). The same insert succeeds on a schema that has no such property, so the error reveals a hidden column. A merge conflict body for a row outside the view is the same class: line 565 reports conflicts with no entity ids, and a hidden diff row still has to refuse as view_write_denied with no conflict payload. The class is every global check whose outcome depends on hidden rows or hidden schema. The inline comment states the rows to add.

  4. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 still names public or uniform statistics. Lines 1331–1336 and the decision log (lines 1938–1941) say the polymorphic-types draft now names view statistics for restricted actors. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 at fca9e350 is open and was updated after this commit. Its search section still says index-wide statistics for bypass actors and view-independent (public or uniform) statistics for restricted actors (that draft, lines 567–575). Its policy section still checks an interface-wide mutation before staging (that draft, lines 761–762). This RFC already asks rfc: polymorphic types with interfaces, unions and polymorphic edges #884 to name attempt validation and commit revalidation (lines 1349–1352). The statistics sentence describes a draft that still contains the source this revision withdrew. Accepting both texts would put the public-statistics leak back beside D10. The inline comment states the correction. rfc: analyzed lexical search on the shared expression model #792 at c5d33c2931a1 already matches D10: the population is policy-visible entities with nonempty analyzed values, and a graph filter does not rescope it.

Optional findings

  • Say what the flat operator actually reads. Lines 1093–1095 say no postings are read. FlatMatchQueryExec::execute still opens every committed segment to take the tokenizer (lance-11.0.0 fts.rs:3171-3191; the comment at fts.rs:284-285 says the open is for per-segment corpus statistics). With a preset scorer it skips build_global_bm25_scorer. The score comes from initialize_scorer adding the flat input to that base (flat_search.rs:556-587). State that scores use neither postings nor index statistics, and that the design depends on that add. On the indexed path, with_base_scorer replaces the global scorer. A later Lance that keeps a preset flat scorer unchanged would make a zero base score nothing. The identical-score guard is the pin that catches it. Empty or null documents are dropped before the scorer (flat_search.rs:369-381). Document count and average length cover the visible rows that tokenize to at least one token. Equal views still give equal scores, and this matches rfc: analyzed lexical search on the shared expression model #792's nonempty rule. Line 541 should say "the rows the operator counts."
  • fix(schema): drop a property together with the constraints that name only it #1062 is on main. Line 1304 cites fix(schema): drop a property together with the constraints that name only it #1062 as a draft that would drop a placement property together with the constraints that name only it. fix(schema): drop a property together with the constraints that name only it #1062 merged at ff9519fe on 2026-10-18, after this head. At 9575b044 the citation was an open draft. On the next rebase the sentence describes current main. Keep the bypass-only removal rule.
  • Ignore a member or deny edge on the internal group at read time too. The closure follows a live @member edge (lines 946–950). The refusal of @member into internal and @deny onto it is a write rule (lines 301–302). A read that ignores those edges keeps a missed writer from granting the company-wide set. The write rule stays the main control.
  • Two sentences overstate a current fact. Line 705 says branches hold no access facts, in the branch-list row. Line 884 says placement values also live on branches. The branch-list row should say the list returns names and heads. Appendix A marks "open mode allows all graph actions" as current behavior (line 2027). authorize denies every server-scoped action when no cluster policy is installed, including --unauthenticated (crates/omnigraph-server/src/handlers.rs:664-670). The row should say every per-graph action.

Tradeoffs and long-term cost

This revision removes mechanisms: public statistics, uniform statistics, approximate restricted nearest, and a view filter that rides the user's eligibility. The accepted contract is smaller than the previous draft. Restricted search spends work linear in the visible set, bounded by max_exact_search_rows. Efficiency is the quality this acceptance defers. The two blocked_on measurements stay required before any acceleration. Clarity of the contract, and one rule per lifecycle, are the acceptance criteria.

The remaining cost is real. Phases 0–3 still add a policy format, actor-taking read doors, view-bound acceptance, a protected lifecycle and redaction across every surface. That cost stays justified when one capture rule and one validation rule cover every caller. Five more scoring paths or replay exceptions would multiply the security surface. The identity check and the branch-write rule are the two places where a narrower sentence still sits beside the contract.

The core choices stay sound. Access facts live in the graph and publish through __manifest. There is no second authorization store. The fixed vocabulary keeps the readable set computable. Plan acceptance stays the one read door, and the view digest reuses catalog_digest. The internal group matches the control-plane fact that an invitation joins the organization, so organization membership is separate from the company wall.

Alignment

Validation and limits

Reviewed detached head 7f3d601e634d07b17eb7646c8280c15d550ad8c5. git diff --stat 44daa2c2 HEAD on the RFC is +1096/−809. The merge of main at 9575b044 (08b982de) is outside that count. git diff --check 44daa2c2 HEAD on the RFC and docs/rfcs/README.md: passed.

  • python3 scripts/check-docs.py with the pinned scripts/requirements-docs.txt parser: Documentation OK (333 Markdown files checked).
  • bash scripts/check-agents-md.sh: AGENTS.md ↔ docs indexes OK (57 links, 53 docs).
  • typos 1.50.1 on the RFC and docs/rfcs/README.md: passed.

CI on this exact commit: CI succeeded. Check AGENTS.md Links succeeded, which runs the documentation checker. Test Workspace, clippy and rustfmt were skipped because only docs changed. GQ Logic Tests succeeded with the GQT steps skipped. DST succeeded with the pinned suite skipped. CI gives no runtime proof.

No actor-view runtime exists on 44daa2c2 or on this head, so a before/after GQT or Rust regression cannot prove the proposal. No temporary test was added. No source was edited. The behavioral claims about current code and Lance 11.0.0 were checked by reading the pinned sources and the engine at this head. I did not re-run fts_prefilter_changes_unindexed_scores_and_postfilter_keeps_them; the eligibility rule was re-read at the lines cited above. No scale, recall, latency, S3 or Azure result is claimed. The two blocked_on measurements are unrun.

access tables are not part of, so the compare-and-swap cannot fence a
revocation on `main`. Such a write is authorized against the authorization
state captured in its attempt; a revocation published on `main` during that
attempt can miss at most that attempt. Every later read applies the current

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking. This one-attempt miss disagrees with the write contract and with the Phase 3 exit.

Lines 149–152 say a write succeeds only when every touched row is writable in the actor's view. This paragraph says a revocation on main during a branch attempt can miss that attempt. Lines 1869–1872 say a write that races an access change in another process retries or refuses. "What is not promised" does not list the window.

commit_all takes the schema permit shared, then the attempt branch's gate (crates/omnigraph/src/exec/staging.rs:973-979). The comment at staging.rs:942-943 says the manifest compare-and-swap is the foreign-process authority. Appendix A records the same fact (line 2038). A per-branch gate does not serialize a main revocation against a feature-branch commit. ExactGraphHead fences a publish on main because an access change moves main's head. It does not fence another branch's chain.

This is invariant 3: one attempt uses one coherent authority, and a retry starts fresh. The class is every publication whose chain is not main. Put one rule in the contract, in this section and in the Phase 3 exit:

  • If acceptance allows the miss, add the window to "What is not promised" and state it in lines 149–152 and in the Phase 3 exit.
  • If acceptance refuses the miss, re-read main's access pins at branch commit under a gate that also holds main's branch gate, and write the lock order down. A revocation on main then waits until the branch commit finishes. Keep ExactGraphHead for publishes on main.

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.

Addressed in 90f3ec0 by stating the window rather than closing it. No compare-and-swap spans main's chain and a branch's chain. Holding main's gate in commit_all would be a process-local lock that fences nothing across processes, which the deny-list rejects.

  • The write contract now says a write is checked at its attempt's captured authorization state. On main that is also the state at publication.
  • What is not promised states the one-attempt window and its bounds: the write deadline, rows landing only on that branch, every later read applying the revocation, and a merge into main re-checking the rows for the merging actor.
  • Capturing a view cites the gate order in commit_all. The Phase 3 exit and a DST case test exactly that window.

Refusals carry `identity_binding_denied` or, for a named securable,
`securable_in_use`. With these rules a restricted writer cannot take over
another person's identity by moving an actor id, cannot squat a securable key
before a connector mirrors it, and cannot inherit rows by re-creating a deleted

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking. The delete check covers one committed state, so a recreated string key still inherits branch rows.

Line 360 says a recreated key inherits no rows. The check at lines 983–995 reads the committed state of the write: one IsIn on each per-row placement BTREE, plus each fixed @in. Placement values also live on branches (line 884). Ordinary branch reads are in Phases 0–3 and follow the branch's placements (lines 681–682). Securable creates are outside the main-only list, which covers access edges and @principal (lines 575–576).

A bypass offboarding can clear the placements on main, delete the securable and create a new securable with the same string. IsIn on main sees no placement. A branch forked earlier still stores rows whose @in is that string. A reader of the branch resolves the string through the new securable and sees the old rows.

The class is every string placement that outlives the securable row: another live branch in the first scope, and any snapshot a later publish or a Phase 9 read can still open. Invariant 6 says a dropped identity is not the next row that reuses the name. D9 adopts that rule for securable keys. The check belongs on the identity lifecycle and has to see every snapshot the accepted scope can read.

State all three of these:

  • Securable rows and the internal group are written only on main, with the access edges.
  • Delete is refused while any live branch still stores a placement that names the key.
  • A branch read resolves a placement through the captured securable row. A key that was deleted and created again is a new securable, so the old branch rows stay hidden. Phase 6 packed references are the durable form of that rule. Until they land, the first scope can claim "a recreated key inherits nothing" only when branch reads already follow that resolution, or when create-after-delete of the string is refused while any such branch remains.

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.

Addressed in 90f3ec0. A delete-time check cannot see every branch chain or retained snapshot, so the first scope never deletes a securable.

  • A securable is retired by deleting its access edges and moving or deleting its rows. Its key-only row stays.
  • Securable, group and access rows and @principal values are written only on main.
  • No key ever names a second securable, on any branch or snapshot.
  • Keys are immutable source ids. The example now keys Person by identity-provider subject, and the lint flags human-meaningful securable keys.

Deletion returns with Phase 6 packed references.

**Search across members.** That RFC admits `bm25` across members only under one
corpus contract, with the statistics source this RFC selects, through one engine
module shared by both RFCs, and refuses it until an upstream Lance scanner
option ships. This revision changes the source it names for restricted actors

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking. #884 does not yet name view statistics.

This sentence, and the decision log at lines 1938–1941, say the polymorphic-types draft now names view statistics for restricted actors. #884 at fca9e350 is open and was updated after this commit. Its search section still says index-wide statistics for bypass actors and view-independent (public or uniform) statistics for restricted actors (that draft, lines 567–575). Its policy section still checks an interface-wide mutation before staging (that draft, lines 761–762).

Lines 1349–1352 already ask #884 to name attempt validation and commit revalidation. The statistics sentence describes a settlement that the other draft does not contain. Accepting both texts would put the public-statistics leak back beside D10.

Correct this paragraph and the decision log: #884 still has to drop the public/uniform sentence, name view statistics, and name the same write point. The single-member flat operator here and #884's "needs an upstream scanner option" are a disagreement to write down in both drafts. #792 at c5d33c2931a1 already matches D10: policy-visible entities with nonempty analyzed values, and a graph filter does not rescope the statistics.

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.

#884 adopted view statistics in 98a4ed8 (2026-10-18), after this review, and now records the same plan split:

  • The bypass route waits for the upstream scanner option.
  • The restricted route reads no postings and builds its flat plan in the engine. Each arm's base scorer is the other arms' view statistics, collected through the public flat_bm25_search_stream_with_options_and_scorer.

Its Policy section now names attempt validation and the commit revalidation (24b60eb). 90f3ec0 rewrites this paragraph and the decision log to match, and keeps multi-member bm25 refused for every actor until #884 admits it. #792's population, visible rows with nonempty analyzed values that graph filters do not rescope, is now stated explicitly.

of scope for live access; they are one reason external parties get
materialized shares.
- **Write disclosures.** A writer of a placed type can learn, from whether its
write succeeds, exactly these facts and nothing more

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking. "Exactly these facts" leaves two channels out, and the @card row covers only the maximum.

card_violations reports the maximum and then the minimum (crates/omnigraph/src/validate/kernels.rs:494-515). The message names the source and the count (kernels.rs:578). Deleting the last visible edge fails when the global count would fall below min, and succeeds when hidden edges keep the count at or above min. Success versus failure reveals hidden edges. "Reached the bound" in the table (line 593) describes the maximum. State the minimum channel, or evaluate min and max on the visible edges where hidden edges are not required for integrity.

A fixed @in on a non-null property removes that property from the schema view, so a restricted insert cannot set it. resolve_assignments refuses a null on a non-nullable property before any batch and names the property (crates/omnigraph/src/exec/mutation.rs:19 and mutation.rs:32-36). The same insert succeeds on a schema with no such property. The error reveals a hidden column. Schema apply must refuse a non-null property with a fixed @in, or the property must be nullable and an omitted value must stay null with no distinct error.

A merge whose diff contains a row outside the view is the same class. Line 565 reports conflicts with no entity ids. That refusal has to be view_write_denied with no conflict payload.

The class is every global check whose outcome depends on hidden rows or hidden schema. Add each one to this list and to the table, or change the check so the outcome follows the visible rows.

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.

Addressed in 90f3ec0. The table is now stated as complete for write checks.

  • @card minimum. It has its own row (on an edge delete) beside the maximum. The server strips the source and count from card_violations' message.
  • Hidden columns. Schema apply refuses a fixed @in on a non-null property. A writer who cannot see the column leaves it null, and no error names it.
  • Merge. A restricted merge is refused with view_write_denied and no conflict payload unless every row changed on either side since the merge base is writable. That hidden rows changed is a listed disclosure.
  • Schema apply and maintenance. Their validations and reports read every row, so they need a bypass actor.

- State the one-attempt window in which a branch write can miss a
  revocation on main, in the contract, What is not promised and the
  Phase 3 exit: no compare-and-swap spans two branch chains, and the
  write gates are process-local.
- Never delete a securable in the first scope, and write securable,
  group and access rows only on main, so no key ever names a second
  securable on a branch or in a retained snapshot. Key securables by
  immutable source ids; deletion returns with packed references.
- List every write check whose outcome depends on hidden rows or schema:
  add the @card minimum and branch merge, make placed columns nullable,
  refuse a restricted merge with hidden changes without a conflict
  payload, and require a bypass actor for schema apply and maintenance.
- Restrict bm25 statistics to visible rows with nonempty analyzed text,
  unaffected by the query's filters.
- Align with the polymorphic types draft's 2026-10-18 corpus procedure.
…ition model

Use the one cluster-backed serving model, the Omnigraph-If-Actor assertion and the disposition object, and settle a restricted writer's unknown outcome through disposition.check once that phase ships.

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: request changes before acceptance. This pull request changes documentation only. The findings are about the proposed behavior.

What this revision does

Why. The review of 7f3d601e left four gaps in the contract that Phases 0–3 ask to accept.

What. This revision states the one-attempt branch-write window, keeps a securable key for the life of the graph, extends the disclosure table, and follows the graph access RFC on serving and disposition.

How. 90f3ec0f and 5f14b1ba change the RFC by +232/−144 against 7f3d601e.

Impact. The window and the statistics agreement with #884 can stand. Three rules still name the wrong door, or they leave a path of the same class open.

Earlier findings

From the review of 7f3d601e:

  1. Identity checked on one committed state: partly. Row delete, recreate, and branch writes of securable rows are closed. Schema apply can still drop the type. See blocking finding 2.
  2. Branch-write window: partly. The contract, What is not promised, Capturing a view, and the Phase 3 exit state one window. Invariant 3 still gives every write the publication fence. See blocking finding 3.
  3. Disclosure list: partly. The @card minimum, nullable placed columns, and a hidden row on merge are in the table. A hidden column on a visible row is not. See blocking finding 4.
  4. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 statistics: resolved. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 at 24b60eba names view statistics and the same plan split. Both drafts now share a write-point sentence that names the wrong function. See blocking finding 1.

Optional items from that review:

  • Flat operator: partly. Nonempty rows, the zero base, and filters after scoring are stated. "No postings are read" is stronger than FlatMatchQueryExec::execute.
  • fix(schema): drop a property together with the constraints that name only it #1062: resolved. Placement removal stays a bypass schema apply, and the text names the silent drop.
  • Internal-group edges at read time: open.
  • Branch-list sentence and the open-mode appendix row: partly. The list row now says names and heads, and it still says branches hold no access facts. The appendix row is unchanged.

Blocking findings

  1. Put the view check in the pre-effect evaluator. commit_staged_mutation calls validate_staged_mutation, then stage_all_with_concurrency, then commit_all (crates/omnigraph/src/exec/mutation.rs). validate_staged_mutation calls validate_changeset on the in-memory change set. Insert placement values are columns on those rows. Deleted ids are on the change set, and the committed row holds the placement before the write. The load path calls validate_changeset_with_access, then stage_all. A refusal there writes no file. commit_all calls revalidate_write_txn, which compares the branch identifier, the graph head, and the accepted catalog. It does not evaluate @key, @unique, @card, or a view. Merge collects MergeConflict values inside stage_streaming_table_merge and returns them before validate_merge_candidates. That function calls evaluate on the in-memory change set. The dry run then stops, before the first durable effect. The sentence "a check before staging could not see staged placement values" describes lower_named_mutation, which runs before any row exists. Name one row check: validate_changeset for mutation and load, and evaluate for merge, over the change set and the committed before-image, before any staged file. A hidden-row merge refusal happens before the conflict list is returned. Keep revalidate_write_txn and merge's final re-read as the authority token. A view refusal writes no file and publishes nothing. Invariant 4 and the Policy bullet in rfc: polymorphic types with interfaces, unions and polymorphic edges #884 should use that sentence. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 at 24b60eba repeats the sentence this finding corrects.

  2. Schema apply can still drop a securable identity. schema_plan.rs emits SchemaMigrationStep::DropType for a removed node or edge type. Apply tombstones that table in __manifest and does not require the table to be empty (schema_apply.rs). The identity table refuses a row delete for every actor. It does not refuse dropping a @securable or @group type, or dropping or replacing its key property. Schema apply on an actor-views graph needs a bypass actor, so that path is open. A later apply can add the type again and create the same string key. Placements that still store the string then name the new securable. Retained snapshots hold those strings. rfc: schema apply with live branches #963 is the draft that lifts the single-live-branch fence, so that fence is not the identity rule. Add the schema routes to the lifecycle: schema apply refuses DropType of a securable or group type, and refuses dropping or replacing its key property, until packed references make deletion safe.

  3. Two summary sentences still state rules this revision withdrew. Invariant 3 says a write's access pins are expected versions of its publication. A branch write publishes on that branch's chain. main's access pins are not part of that compare-and-swap. The window section is the rule for that publication. Invariant 10 says direct and local access are storage-holder access. The serving table now has one cluster-backed server, including on localhost, and that server applies the actor's view. Direct CLI and embedded access are the storage-holder path.

  4. A hidden column on a visible row is still a write of hidden state. An update leaves a hidden column unchanged. Merge compares full rows (cursor_rows_equal in exec/merge.rs). When one side changed, the merge writes that side's full row. A change to only a hidden column publishes a value the actor cannot see. When both sides change that column, classify_merge_conflict names the visible row id. The disclosure table covers a row outside the view. It does not cover a property outside the view. Treat a change to a property the actor cannot see as view_write_denied with no payload. Compare and merge the visible properties.

Optional findings

  • view_limit_exceeded uses HTTP 422. rfc: standardize HTTP API conventions #1089 at a85ffdca assigns an out-of-range limit to 400, and its status list has no 422. The other new codes use 400, 403, and 409, which that list allows.
  • FlatMatchQueryExec::execute in Lance 13.0.0 opens every committed segment (open_fts_segments) to take the tokenizer, then uses the preset scorer and skips build_global_bm25_scorer. Scores do not use those postings. State that. The zero base depends on initialize_scorer adding the flat counts to the supplied base (Lance 13 lance-index flat_search.rs line 756).
  • Re-anchor the Lance 11.0.0 line citations to Lance 13.0.0. main at dc92f9f2 pins Lance 13.0.0 (build(storage): upgrade lance to 13.0.0 #1072). The load-bearing behavior still holds. Empty row-level documents are dropped when all_tokens == 0. with_shared_scorer is pub(crate). find_partitions uses the query key. The late search stops at k. The cited line numbers moved. The new appendix row cites flat_search.rs:857 for a function that is at line 1074 in Lance 13.
  • The branch-list row still says branches hold no access facts. Placement values live on branches. The appendix row "open mode allows all graph actions" is still marked confirmed. authorize denies every server-scoped action when no policy is installed, including --unauthenticated. Per-graph actions are open in that mode. Static tokens without a policy still allow read only.
  • A read of the internal group should ignore an explicit @member edge into it and a @deny edge onto it. The write refusal stays the main control.
  • rfc: graph access and cross-process writes #1017's later disposition.check example uses the field attempt. This RFC says "publication id". Use attempt when that phase lands.

Tradeoffs and long-term cost

This revision removes a cross-chain lock and a delete-time scan of every branch. That matches the deny-list and invariant 6. The cost is an explicit one-attempt window and a key row that stays. That is the right trade for the first scope. Approximate search stays deferred, so efficiency stays the quality this acceptance does not buy.

The remaining cost is a second description of the write door. validate_changeset already evaluates @key, @unique, and @card once, before any file. A second scan at commit, or a schema path that drops the key, gives the next write feature two places to update. Five more checks would then drift. One pre-effect evaluator, with schema apply in the same lifecycle, keeps one source of truth.

Alignment

Validation and limits

Reviewed 5f14b1baa9971be58c12d8812692db7b6d965458. git diff --stat 7f3d601e634d07b17eb7646c8280c15d550ad8c5 HEAD is +232/−144 on the RFC. git diff --check on that range: clean. Merge base with main dc92f9f2cb8b9c59805665801d8ecc56a5235329 is 9575b044. Code checks used that main. It differs from 7fd9cc90 only in README.md.

Local, on the detached head:

  • python3 scripts/check-docs.py: Documentation OK (333 Markdown files checked).
  • bash scripts/check-agents-md.sh: AGENTS.md ↔ docs indexes OK (57 links, 53 docs).
  • typos 1.50.1 on the RFC and docs/rfcs/README.md: no findings.

CI on this commit: CI Gate succeeded. Check AGENTS.md Links succeeded. Test Workspace, clippy, and rustfmt were skipped. GQ Logic Tests succeeded with the GQT steps skipped. DST succeeded with the pinned suite skipped. CI gives no runtime proof.

No actor-view runtime exists on 7f3d601e or on this head. A before/after GQT or Rust regression cannot prove the proposal. GQT expresses rows and errors. It does not express which function runs before stage_all. That order belongs to commit_staged_mutation, the load path, and validate_merge_candidates. No temporary test was added. No source was edited.

Lance claims were read in the pinned Lance 13.0.0 sources named above. I did not re-run the full-text guard test. No scale, recall, latency, S3, or Azure result is claimed.

Inline comments

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:1212

Blocking. Put this check in validate_changeset before stage_all.

On main dc92f9f2, commit_staged_mutation calls validate_staged_mutation, then stage_all_with_concurrency, then commit_all. validate_staged_mutation calls validate_changeset on the in-memory change set. Insert placements are columns on those rows. Deleted ids are on the change set, and CommittedState holds the before-image. The load path is validate_changeset_with_access, then stage_all. A refusal there writes no file.

revalidate_write_txn compares the branch identifier, the graph head, and the accepted catalog. It does not evaluate the view. Merge returns MergeConflicts from stage_streaming_table_merge before validate_merge_candidates calls evaluate. Refuse a hidden row there, before that list is returned. The dry run already stops before the first durable effect.

"A check before staging could not see staged placement values" describes lower_named_mutation, which runs before any row exists. Say: one row check in validate_changeset / evaluate, before any staged file; a view refusal writes no file and publishes nothing. Use the same sentence in invariant 4 and in #884's Policy bullet (24b60eba).

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:373

Blocking. Schema apply can still drop this identity.

SchemaMigrationStep::DropType tombstones the table in __manifest and does not require the table to be empty (schema_apply.rs). Dropping or replacing the key property is a key change through the schema plan. A bypass schema apply is allowed on an actor-views graph. A later apply can add the type and create the same string. Placements that still store the string, including retained snapshots, then name the new securable.

Add two rows: schema apply refuses DropType of a securable or group type, and refuses dropping or replacing its key property, until packed references make deletion safe. The single-live-branch fence is not this rule. #963 is the draft that lifts it.

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:1628

Blocking. This sentence still gives every write the fence the window section withdrew.

A branch publication's compare-and-swap does not include main's access pins. State the window here: on main, the access pins are expected versions; on another branch, the attempt keeps the captured authorization, and a revocation on main can miss that one attempt.

Invariant 10 still says direct and local access are storage-holder access. The serving table now has one cluster-backed server, including on localhost, and that server applies the view. Direct CLI and embedded access are the storage-holder path.

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:595

Blocking. A hidden column on a visible row still follows the full row.

cursor_rows_equal compares every column. When one side changed, the merge writes that side's full row, so a hidden column is published. When both sides change it, classify_merge_conflict names the visible row id. The update rule leaves a hidden column unchanged and returns no error that names it.

Treat a change to a property outside the view as view_write_denied with no payload. Compare and merge the visible properties. Add that check to the disclosure table.

@ragnorc ragnorc changed the title rfc: actor views and subgraph sharing rfc: granular permissioning Oct 21, 2026
@azimafroozeh azimafroozeh self-assigned this Oct 22, 2026
@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Review of d700c334: request changes before acceptance

Recommendation: request changes. This pull request changes documentation only. This commit does not change the RFC. It merges main. The four gaps from the review of 5f14b1ba are still open. The merge does not add a fifth contract gap.

What this RFC does

Why. Today an actor reads a whole graph branch or none of it. A shared company graph needs per-channel, per-folder, per-column and per-row audiences, plus walls and external shares.

What. An actor view, built from graph rows: groups, securables, membership, grants, inheritance, requirements, denial, time windows and placement. The internal group and the bypass role come from cluster policy.

How. One capture per request. A readable set as a least fixed point with one negation layer. Exact restricted search scored from the visible rows. One protected lifecycle for identity bindings. View requirements and a view digest inside the existing plan-acceptance door.

Impact. Accepting Phases 0–3 adds policy format 2, actor-taking read doors, view-bound plan acceptance, a protected identity lifecycle, exact-search admission and redaction on every restricted surface. This commit adds none of that code.

Earlier findings

The RFC file at this head is byte-identical to 5f14b1ba. Status of the 2026-10-21 review:

  1. Put the view check in the pre-effect evaluator: still open. Blocking finding 1. The call order on this head is the same.
  2. Schema apply can drop a securable identity: still open. Blocking finding 2. DropType still tombstones a table that is not empty.
  3. Two summary sentences still state withdrawn rules: still open. Blocking finding 3.
  4. A hidden column on a visible row: still open. Blocking finding 4. Merge still compares full rows and returns the conflict list before evaluate.

The optional items from that review stay open. The merge changes their evidence. It does not close them.

These earlier items stay resolved. This review does not reopen them: the pair contract, exact restricted search, the internal group, the one-attempt branch-write window in the contract, in What is not promised and in the Phase 3 exit, and the agreement with #884 on view statistics.

Blocking findings

  1. Put the view check in the pre-effect evaluator. The Writes section still says the check runs where the engine has staged rows, that commit_all revalidates the whole transaction before any effect, and that effects staged before a refusal are detached. The polymorphic section still says a check before staging cannot see staged placement values, and that every write is checked again in commit revalidation.

    On this head, commit_staged_mutation calls validate_staged_mutation, then stage_all_with_concurrency, then commit_all (crates/omnigraph/src/exec/mutation.rs). validate_staged_mutation calls validate_changeset on the in-memory change set. Insert placement values are columns on those rows. Deleted ids are on the change set. CommittedState holds the before-image. The load path calls validate_changeset_with_access, then stage_all (crates/omnigraph/src/loader/mod.rs). A refusal there writes no file. commit_all calls revalidate_write_txn, which compares the branch identifier, the graph head and the accepted catalog (crates/omnigraph/src/db/omnigraph.rs). It does not evaluate @key, @unique, @card or a view. Errors from stage_all and commit_all are mapped to OmniError::unpublished. A view refusal in that span would be an unpublished storage outcome, not view_write_denied with effects: none.

    Merge collects conflicts inside stage_streaming_table_merge and returns MergeConflicts before validate_merge_candidates calls evaluate (crates/omnigraph/src/exec/merge.rs). The dry run stops after that evaluation, before the first durable effect. A hidden-row refusal has to happen before that conflict list is returned.

    "A check before staging could not see staged placement values" describes lowering, which runs before any row exists. Name one row check: validate_changeset for mutation and load, and evaluate for merge, over the change set and the committed before-image, before any staged file. A view refusal writes no file and publishes nothing. Keep revalidate_write_txn as the authority token. Use that sentence in invariant 4 and in rfc: polymorphic types with interfaces, unions and polymorphic edges #884's Policy bullet. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 is still at 24b60eba and still repeats the sentence this finding corrects.

    Invariant 4 is the rule. The class is every writer: mutation, load, merge, and an interface-wide mutation. A second scan at commit leaves the cause in place. The next write feature would update one door and miss the other.

  2. Schema apply can still drop a securable identity. The identity table covers row assign, create, key change and delete. It names mutation, load and merge. It does not name schema apply.

    schema_plan emits SchemaMigrationStep::DropType for a removed node or edge type. Apply tombstones that table in __manifest and does not require the table to be empty (schema_apply.rs). Dropping or replacing the key property is a key change through the schema plan. Schema apply on an actor-views graph needs a bypass actor, so that path is open. A later apply can add the type and create the same string. Placements that still store the string, including retained snapshots, then name the new securable.

    Invariant 6 says a dropped identity is not the next row that reuses the name. D9 adopts that rule for string keys by never deleting the row. The same rule has to cover every route that removes the identity. Add two rows: schema apply refuses DropType of a securable or group type, and refuses dropping or replacing its key property, until packed references make deletion safe. The single-live-branch fence is not this rule. rfc: schema apply with live branches #963 at 9a3f6213 is still the draft that lifts it.

  3. Two summary sentences still state rules this revision withdrew. Invariant 3 says a write's access pins are expected versions of its publication. A branch write publishes on that branch's chain. main's access pins are not part of that compare-and-swap. commit_all takes the shared schema permit, then the attempt branch's gate and its table gates (staging.rs). The window section is the rule for that publication: on main, the access pins are expected versions; on another branch, the attempt keeps the captured authorization, and a revocation on main can miss that one attempt.

    Invariant 10 says direct and local access are storage-holder access. The serving table has one cluster-backed server, including on localhost, and that server applies the actor's view. Direct CLI and embedded access are the storage-holder path. Where the engine enforces a policy for the declared actor, it applies the view as a convenience.

    Invariant 3 is the rule for the first sentence. The class is every publication whose chain is not main. Invariant 10 is the rule for the second. The class is every host of the cluster-backed server.

  4. A hidden column on a visible row is still a write of hidden state. The disclosure table says it lists every check whose outcome can depend on hidden rows or hidden schema. It covers a row outside the view. It does not cover a property outside the view.

    An update that omits a hidden column must leave that column unchanged and must return no error that names it. Merge compares full rows (cursor_rows_equal in exec/merge.rs). When one side changed, the merge writes that side's full row, so a hidden column is published. When both sides change it, classify_merge_conflict names the visible row id. That list is returned before evaluate.

    Treat a change to a property the actor cannot see as view_write_denied with no payload. Compare and merge the visible properties. Add that row to the disclosure table. The class is every full-row compare or full-row write, not only a row outside the view.

Optional findings

  • Say what the flat operator reads. "No postings are read" is stronger than FlatMatchQueryExec::execute in Lance 13.0.0. With segments, execute opens every committed segment (open_fts_segments, lance-13.0.0 io/exec/fts.rs around line 4343) to take the tokenizer, then uses the preset base scorer and skips build_global_bm25_scorer (around line 4354). Scores do not use those postings. State that. With no segment, the same function uses default_text_tokenizer. The zero base still depends on initialize_scorer adding the flat counts to the supplied base (lance-index-13.0.0 flat_search.rs line 756). Empty row-level documents are still dropped when all_tokens == 0 (line 373).
  • Re-anchor the Lance citations to the lockfile in this commit. The header says the citations are the versions in Cargo.lock. This commit pins Lance 13.0.0. The appendix still cites Lance 11.0.0 line numbers. The load-bearing behavior holds. with_shared_scorer is still pub(crate) (fts.rs line 4228). FlatMatchQueryExec::new is at line 4114 and with_base_scorer at line 4223. flat_bm25_search_stream_with_options_and_scorer is at flat_search.rs line 1074. find_partitions still uses the query key (ivf/v2.rs line 2197). The early-result buffer still keeps at most k ids (knn.rs line 1696). Code citations that say 9575b044 are a survey pin. They are not this head.
  • Two sentences still overstate a current fact. The branch-list row still says branches hold no access facts. Placement values live on branches. The appendix row "open mode allows all graph actions" is still marked confirmed. authorize denies every server-scoped action when no policy is installed, including --unauthenticated (handlers.rs). Per-graph actions are open in that mode. Static tokens without a policy still allow read only. The serving paragraph that says unauthenticated mode allows every graph action has the same overstatement.
  • Ignore a member or deny edge on the internal group at read time too. The closure follows a live @member edge. The refusal of @member into internal and @deny onto it is a write rule. A read that ignores those edges keeps a missed writer from granting the company-wide set. The write rule stays the main control.
  • view_limit_exceeded uses HTTP 422. The accepted HTTP conventions RFC is in this tree. Its status list has no 422, and it names this draft as the addition. Accepting 422 has to add 422 to that list in the same change. A request limit above its ceiling is 413 in that RFC. This code is a view budget, not that request limit. ErrorCode::http_statuses in omnigraph-api-types has no 422 today.
  • Name the settle field attempt. The accepted graph-access RFC's disposition.check example uses attempt. The prose also says publication id. This RFC says "publication id". Use attempt for the field. disposition.check is still a later phase. It has not shipped. AccessOutput on this head has actor_id, graph_id, graph_wide, branch, target_branch and actions. It has no view field. The graph-access RFC still reserves that field for this draft. CLI access protocol is 2. The Cedar action list stays as it is.
  • Two "open work" labels are merged. rfc: graph access and cross-process writes #1017 is the accepted graph-access RFC in this tree. feat(branch): start a branch at a graph commit #1016 (branch create at a commit) is on main. The history refusal still covers that operation. rfc: branch operations v2 #677 remains open. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 remains open at 24b60eba. rfc: analyzed lexical search on the shared expression model #792 remains at c5d33c2931 and still matches D10. rfc: schema apply with live branches #963 remains the live-branch schema-apply draft.

Tradeoffs and long-term cost

This merge adds no mechanism. The accepted contract is the same smaller contract as 5f14b1ba: no public statistics, no uniform statistics, no approximate restricted nearest, and no view filter that rides the user's eligibility. Restricted search spends work linear in the visible set, bounded by max_exact_search_rows. Efficiency stays deferred. The two blocked_on measurements stay required before any acceleration. That deferral is the right one. The pair contract is checkable. Approximate search is not, on the pinned Lance path.

The remaining cost is a second description of the write door, a schema path beside the identity lifecycle, and two summary sentences beside the window. validate_changeset already evaluates @key, @unique and @card once, before any file. A second scan at commit, or a schema path that drops the key, gives the next write feature two places to update. Five more checks would then drift. One pre-effect evaluator, with schema apply in the same lifecycle, keeps one source of truth. It adds no store. It removes the class of leaks that come from a second door.

The core choices stay sound. Access facts live in the graph and publish through __manifest. There is no second authorization store. The fixed vocabulary keeps the readable set computable. Plan acceptance stays the one read door. The view digest reuses catalog_digest. The one-attempt branch window matches the deny-list: no cross-chain compare-and-swap, and no process-local lock sold as a distributed fence.

Alignment

Validation and limits

Reviewed d700c33455f1c82ef789de14e58538e5cb952e8e. Parents: 5f14b1baa9971be58c12d8812692db7b6d965458 and 9d92f3970311d5229f55b1a1fc06b2d3fba30a55 (main at the merge). git diff --stat 5f14b1ba HEAD -- docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md is empty. docs/rfcs/README.md gains five registry rows that main already had. git diff --check on that range is clean.

Local, on this detached head:

  • python3 scripts/check-docs.py with the pinned scripts/requirements-docs.txt parser: Documentation OK (391 Markdown files checked).
  • bash scripts/check-agents-md.sh: AGENTS.md ↔ docs indexes OK (62 links, 58 docs); engine reads no setting variable.

CI on this exact commit:

  • CI succeeded. Check AGENTS.md Links succeeded, including typos 1.50.1 on the tree. Test Workspace, clippy and rustfmt were skipped. The pull-request diff against main is documentation.
  • GQ Logic Tests succeeded. The ordinary job logged No GQT input changed; skipping the GQ logic tests.
  • DST succeeded. The pinned suite was skipped.

CI gives no runtime proof of the proposal.

No actor-view runtime exists on 5f14b1ba or on this head. A before/after GQT or Rust regression cannot prove the proposal. GQT expresses rows and errors. It does not express which function runs before stage_all. That order belongs to commit_staged_mutation, the load path and validate_merge_candidates. No temporary test was added. No source was edited.

Current-behavior claims were read in the sources named above, at this head, and in the pinned Lance 13.0.0 crates. I did not re-run fts_prefilter_changes_unindexed_scores_and_postfilter_keeps_them. The eligibility rule was re-read: Eligibility::AfterScoring is still chosen for partial or absent coverage (crates/omnigraph-planner/src/optimizer.rs), and scan.rs still turns prefilter off in that case. The RFC still routes restricted bm25 around that path. No scale, recall, latency, S3 or Azure result is claimed. The two blocked_on measurements are unrun.

Inline comments

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:1212

Blocking. Put this check in validate_changeset before stage_all.

On this head, commit_staged_mutation calls validate_staged_mutation, then stage_all_with_concurrency, then commit_all (crates/omnigraph/src/exec/mutation.rs). validate_staged_mutation calls validate_changeset on the in-memory change set. Insert placements are columns on those rows. Deleted ids are on the change set, and CommittedState holds the before-image. The load path calls validate_changeset_with_access, then stage_all (crates/omnigraph/src/loader/mod.rs). A refusal there writes no file.

revalidate_write_txn compares the branch identifier, the graph head and the accepted catalog (crates/omnigraph/src/db/omnigraph.rs). It does not evaluate the view. Errors from stage_all and commit_all are mapped to OmniError::unpublished. A view refusal in that span would not be view_write_denied with effects: none.

Merge returns MergeConflicts from stage_streaming_table_merge before validate_merge_candidates calls evaluate (crates/omnigraph/src/exec/merge.rs). Refuse a hidden row there, before that list is returned. The dry run already stops before the first durable effect.

"A check before staging could not see staged placement values" (the polymorphic section) describes lowering, which runs before any row exists. Say: one row check in validate_changeset / evaluate, before any staged file; a view refusal writes no file and publishes nothing. Use the same sentence in invariant 4 and in #884's Policy bullet (24b60eba).

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:366

Blocking. Schema apply can still drop this identity.

The table covers mutation, load and merge. SchemaMigrationStep::DropType tombstones the table in __manifest and does not require the table to be empty (schema_plan.rs, schema_apply.rs). Dropping or replacing the key property is a key change through the schema plan. A bypass schema apply is allowed on an actor-views graph. A later apply can add the type and create the same string. Placements that still store the string, including retained snapshots, then name the new securable.

Add two rows: schema apply refuses DropType of a securable or group type, and refuses dropping or replacing its key property, until packed references make deletion safe. The single-live-branch fence is not this rule. #963 at 9a3f6213 is still the draft that lifts it.

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:1628

Blocking. This sentence still gives every write the fence the window section withdrew.

A branch publication's compare-and-swap does not include main's access pins. commit_all takes the shared schema permit, then the attempt branch's gate and its table gates (crates/omnigraph/src/exec/staging.rs). State the window here: on main, the access pins are expected versions; on another branch, the attempt keeps the captured authorization, and a revocation on main can miss that one attempt.

Invariant 10 still says direct and local access are storage-holder access. The serving table has one cluster-backed server, including on localhost, and that server applies the view. Direct CLI and embedded access are the storage-holder path.

docs/rfcs/2026-10-11-actor-views-and-subgraph-sharing.md:617

Blocking. A hidden column on a visible row still follows the full row.

cursor_rows_equal compares every column (crates/omnigraph/src/exec/merge.rs). When one side changed, the merge writes that side's full row, so a hidden column is published. When both sides change it, classify_merge_conflict names the visible row id. That list is returned before evaluate.

The update rule leaves a hidden column unchanged and returns no error that names it. The table covers a row outside the view. It does not cover a property outside the view.

Treat a change to a property outside the view as view_write_denied with no payload. Compare and merge the visible properties. Add that row here.

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.

3 participants