Visibility & Access¶
Memory you cannot scope is memory you cannot share. A single KnowledgeBase often holds claims
belonging to many participants — some public, some private to one person, some scoped to a team or
organization. uniko attaches a visibility scope to the memory types that carry sensitive
claims and filters every recall against the identity of whoever is asking, so nothing leaves a
query that the caller is not entitled to see.
Why visibility matters¶
Recall fuses results across the whole graph. In a multi-tenant system that is a
privacy hazard: a naïve cascade would happily return a Fact that was only ever
meant for one participant — leaking Alice's private claim into Bob's results.
uniko gates every recall query against the asker's identity, so memory can be
shared safely in one KnowledgeBase. The mechanism has three parts:
- Tag a claim with who may see it, at write time.
- Resolve the asking participant's group memberships, at read time.
- Filter the assembled result bundle before it reaches the caller.
Visibility is enforced for the two node types that carry free-form,
person-specific claims — Fact and Observation. Other node types have no
visibility scope and are never policy-checked.
Visibility scopes¶
Visibility lives on the node's optional visibility property
(DataType::String, nullable on both the Fact and Observation labels).
A null or empty value means public. Four scope forms are recognised:
| Scope string | Admits |
|---|---|
null / "" / "public" |
Every viewer. |
"private:{participant_id}" |
Only the viewer whose participant_id matches. |
"team:{team_id}" |
Viewers who are members of team_id. |
"org:{org_id}" |
Viewers who are members of org_id (direct or via a team). |
Secure by default: unrecognized scopes deny
Any visibility string that doesn't match a known prefix (private:, team:, org:)
admits no one. A typo locks a claim down — it can never accidentally widen access.
The single-string check is exposed as visibility_admits, re-exported from
uniko_api::tools (originally uniko_memory::policy):
use uniko_api::tools::{Viewer, visibility_admits};
let alice = Viewer::from_parts("alice", ["red".to_string()], ["acme".to_string()]);
assert!(visibility_admits(None, &alice)); // public
assert!(visibility_admits(Some("public"), &alice)); // public
assert!(visibility_admits(Some("private:alice"), &alice));
assert!(visibility_admits(Some("team:red"), &alice)); // member of red
assert!(visibility_admits(Some("org:acme"), &alice)); // member of acme
assert!(!visibility_admits(Some("private:bob"), &alice));
assert!(!visibility_admits(Some("secret:42"), &alice)); // fails closed
The Viewer¶
A Viewer is the identity that recall results are filtered against. It carries
the requesting participant's id plus its resolved memberships, so that each
per-item check stays O(1):
pub struct Viewer {
pub participant_id: String,
pub teams: HashSet<String>,
pub orgs: HashSet<String>,
}
You can build one in two ways.
Viewer::new walks the membership edges in the store once per call and
caches the result:
Membership is the union of:
- direct
(:Participant)-[:MEMBER_OF]->(:Organization)edges, and - teams reached via
(:Participant)-[:PART_OF_TEAM]->(:Team), plus the orgs those teams sit in via(:Team)-[:TEAM_IN_ORG]->(:Organization).
An unknown participant_id resolves to empty memberships — not an
error. Such a viewer can still see anything public or
private:{their_id}.
Filtering a bundle¶
Recall returns a ContextBundle — a ranked list of RecallItems. Visibility
is applied as a filter over that bundle, just before it is handed to the
caller, via filter_bundle:
use uniko_api::tools::Viewer;
use uniko_memory::policy::filter_bundle;
let viewer = Viewer::new(&kb, "alice").await?;
filter_bundle(&kb, &mut bundle, &viewer).await?;
// `bundle.items` now contains only Facts/Observations Alice may see,
// plus all non-policy node types untouched.
filter_bundle mutates the bundle in place:
- It collects the
node_ids of items whosekindisFactorObservation. If there are none, it returns immediately. - It batch-fetches their
visibilityproperties in a single store call (fetch_visibilities), keeping the filter O(items). - It retains each policy item only when
visibility_admitsaccepts it for the viewer. Items with no fetched visibility are kept (treated as public). - It recomputes
total_tokensusing the sameitems.len() * 50heuristic the recall cascade uses.
flowchart LR
A[ContextBundle] --> B{kind in Fact, Observation?}
B -- no --> K[keep]
B -- yes --> C[fetch_visibilities batch]
C --> D{visibility_admits viewer?}
D -- yes --> K
D -- no --> X[drop]
K --> R[Filtered bundle to caller]
Visibility applies to claims
Visibility scopes the two node types that carry person-specific claims — Fact and
Observation. Structural nodes (Messages, Chunks, Entities, Topics, Summaries, Goals,
Tasks, Episodes) carry no scope and pass through filter_bundle unchanged.
Enforce visibility outside recall
visibility_admits is public precisely so you can gate direct lookups —
for example, a single Fact fetched by id — without routing them through
the recall cascade. Build a Viewer once and call visibility_admits per
item.
Where the scope is written¶
The visibility property is written by ingest and consolidation when a claim
is produced, and read back only at recall assembly time — visibility is never
queried unfiltered from outside the policy module. See
Recall for how the ContextBundle is assembled before filtering,
and the data model for the full property set of the scoped
Fact and Observation node types.