Strict Tag Filtering
Pre-search isolation for multi-tenancy.
Strict Tag Filtering - Design Document
Version: 1.0 Date: 2026-01-24 Author: Claude Code Status: Implemented
1. Overview
This document details the implementation of strict tag-based filtering for Fortémi search. The feature enables guaranteed result segregation by SKOS concepts and schemes.
Implementation Notes (2026-02-01):
- All tag matching is case-insensitive using `LOWER()` function
- All tag filters support hierarchical prefix matching (e.g., `project` matches `project/alpha`)
- Both simple string tags (`note_tag` table) and SKOS concepts (`note_skos_concept` table) are supported
2. Goals
1. Strict Isolation: 100% guarantee that filtered results match criteria 2. Composability: Works alongside existing fuzzy search (FTS + semantic) 3. Ergonomic API: Support both UUID and notation-based filtering 4. Performance: Minimal overhead for unfiltered searches 5. Extensibility: Foundation for multi-tenancy and access control
3. Non-Goals
- Row-level security (future feature)
- Real-time filter updates (eventual consistency acceptable)
- Cross-database federation
4. Technical Design
4.1 Data Model Changes
New Types in `matric-core/src/search.rs`
/// Strict tag filter configuration.
///
/// All conditions are combined with AND at the top level:
/// - Note must satisfy ALL required_concepts (AND within)
/// - Note must satisfy ANY of any_concepts (OR within)
/// - Note must satisfy NONE of excluded_concepts
/// - Note must be within required_schemes (if specified)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StrictTagFilter {
/// Notes MUST have ALL these concepts tagged (AND logic).
/// Empty = no requirement.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_concepts: Vec<Uuid>,
/// Notes MUST have AT LEAST ONE of these concepts (OR logic).
/// Empty = no requirement.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub any_concepts: Vec<Uuid>,
/// Notes MUST NOT have ANY of these concepts (exclusion).
/// Empty = no exclusions.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub excluded_concepts: Vec<Uuid>,
/// Notes MUST have concepts ONLY from these schemes.
/// If non-empty, notes with concepts from other schemes are excluded.
/// Empty = allow all schemes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_schemes: Vec<Uuid>,
/// Notes MUST NOT have concepts from these schemes.
/// Empty = no scheme exclusions.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub excluded_schemes: Vec<Uuid>,
/// Minimum tag count requirement (note must have >= N tags).
#[serde(skip_serializing_if = "Option::is_none")]
pub min_tag_count: Option<i32>,
/// Include notes with NO tags at all.
/// Default: true (untagged notes are included unless filtered out).
#[serde(default = "default_include_untagged")]
pub include_untagged: bool,
}
fn default_include_untagged() -> bool {
true
}
impl StrictTagFilter {
pub fn new() -> Self {
Self::default()
}
/// Add a required concept (note MUST have this tag).
pub fn require_concept(mut self, concept_id: Uuid) -> Self {
self.required_concepts.push(concept_id);
self
}
/// Add to "any" concepts (note MUST have at least one).
pub fn any_concept(mut self, concept_id: Uuid) -> Self {
self.any_concepts.push(concept_id);
self
}
/// Exclude a concept (note MUST NOT have this tag).
pub fn exclude_concept(mut self, concept_id: Uuid) -> Self {
self.excluded_concepts.push(concept_id);
self
}
/// Restrict to specific schemes only.
pub fn require_scheme(mut self, scheme_id: Uuid) -> Self {
self.required_schemes.push(scheme_id);
self
}
/// Exclude notes with concepts from this scheme.
pub fn exclude_scheme(mut self, scheme_id: Uuid) -> Self {
self.excluded_schemes.push(scheme_id);
self
}
/// Check if filter has any criteria.
pub fn is_empty(&self) -> bool {
self.required_concepts.is_empty()
&& self.any_concepts.is_empty()
&& self.excluded_concepts.is_empty()
&& self.required_schemes.is_empty()
&& self.excluded_schemes.is_empty()
&& self.min_tag_count.is_none()
&& self.include_untagged
}
/// Check if scheme filtering is active.
pub fn has_scheme_filter(&self) -> bool {
!self.required_schemes.is_empty() || !self.excluded_schemes.is_empty()
}
}
/// Input format for API (supports notations instead of UUIDs).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StrictTagFilterInput {
/// Concept notations or labels (resolved to UUIDs).
#[serde(default)]
pub required_tags: Vec<String>,
#[serde(default)]
pub any_tags: Vec<String>,
#[serde(default)]
pub excluded_tags: Vec<String>,
/// Scheme notations (resolved to UUIDs).
#[serde(default)]
pub required_schemes: Vec<String>,
#[serde(default)]
pub excluded_schemes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_tag_count: Option<i32>,
#[serde(default = "default_include_untagged")]
pub include_untagged: bool,
}
4.2 Search Request Updates
Updated `HybridSearchConfig`
pub struct HybridSearchConfig {
// ... existing fields ...
/// Strict tag filter (applied before fuzzy search).
pub strict_filter: Option<StrictTagFilter>,
}
impl HybridSearchConfig {
/// Set strict tag filter.
pub fn with_strict_filter(mut self, filter: StrictTagFilter) -> Self {
self.strict_filter = Some(filter);
self
}
}
Updated `SearchRequest`
pub struct SearchRequest {
// ... existing fields ...
/// Strict tag filter.
strict_filter: Option<StrictTagFilter>,
}
impl SearchRequest {
/// Add strict tag filter.
pub fn with_strict_filter(mut self, filter: StrictTagFilter) -> Self {
self.strict_filter = Some(filter);
self
}
}
4.3 Database Query Generation
Implementation Pattern
The actual implementation uses case-insensitive matching with hierarchical prefix support:
Required Tags (AND logic):
EXISTS (
SELECT 1 FROM note_tag nt
WHERE nt.note_id = n.id
AND (
LOWER(nt.tag_name) = LOWER($1::text)
OR LOWER(nt.tag_name) LIKE LOWER($1::text) || '/%'
)
)
Any Tags (OR logic):
EXISTS (
SELECT 1 FROM note_tag nt
WHERE nt.note_id = n.id
AND (
LOWER(nt.tag_name) = ANY(SELECT LOWER(unnest($1::text[])))
OR EXISTS (
SELECT 1 FROM unnest($1::text[]) AS t
WHERE LOWER(nt.tag_name) LIKE LOWER(t) || '/%'
)
)
)
Excluded Tags (NOT logic):
NOT EXISTS (
SELECT 1 FROM note_tag nt
WHERE nt.note_id = n.id
AND (
LOWER(nt.tag_name) = ANY(SELECT LOWER(unnest($1::text[])))
OR EXISTS (
SELECT 1 FROM unnest($1::text[]) AS t
WHERE LOWER(nt.tag_name) LIKE LOWER(t) || '/%'
)
)
)
New Module: `matric-db/src/strict_filter.rs`
use uuid::Uuid;
use crate::StrictTagFilter;
/// Generates SQL WHERE clause fragments for strict tag filtering.
pub struct StrictFilterQueryBuilder {
filter: StrictTagFilter,
param_offset: usize,
}
impl StrictFilterQueryBuilder {
pub fn new(filter: StrictTagFilter, param_offset: usize) -> Self {
Self { filter, param_offset }
}
/// Build the complete WHERE clause fragment.
/// Returns (sql_fragment, params).
pub fn build(&self) -> (String, Vec<QueryParam>) {
let mut clauses = Vec::new();
let mut params = Vec::new();
let mut param_idx = self.param_offset;
// Required concepts (AND): must have ALL
for concept_id in &self.filter.required_concepts {
param_idx += 1;
clauses.push(format!(
"EXISTS (SELECT 1 FROM note_skos_concept nsc WHERE nsc.note_id = n.id AND nsc.concept_id = ${})",
param_idx
));
params.push(QueryParam::Uuid(*concept_id));
}
// Any concepts (OR): must have AT LEAST ONE
if !self.filter.any_concepts.is_empty() {
param_idx += 1;
clauses.push(format!(
"EXISTS (SELECT 1 FROM note_skos_concept nsc WHERE nsc.note_id = n.id AND nsc.concept_id = ANY(${}::uuid[]))",
param_idx
));
params.push(QueryParam::UuidArray(self.filter.any_concepts.clone()));
}
// Excluded concepts (NOT): must have NONE
if !self.filter.excluded_concepts.is_empty() {
param_idx += 1;
clauses.push(format!(
"NOT EXISTS (SELECT 1 FROM note_skos_concept nsc WHERE nsc.note_id = n.id AND nsc.concept_id = ANY(${}::uuid[]))",
param_idx
));
params.push(QueryParam::UuidArray(self.filter.excluded_concepts.clone()));
}
// Required schemes: notes must ONLY have concepts from these schemes
if !self.filter.required_schemes.is_empty() {
param_idx += 1;
// Two conditions:
// 1. Must have at least one concept from required schemes
// 2. Must NOT have any concept from other schemes
clauses.push(format!(
r#"(
EXISTS (
SELECT 1 FROM note_skos_concept nsc
JOIN skos_concept sc ON sc.id = nsc.concept_id
WHERE nsc.note_id = n.id AND sc.primary_scheme_id = ANY(${}::uuid[])
)
AND NOT EXISTS (
SELECT 1 FROM note_skos_concept nsc
JOIN skos_concept sc ON sc.id = nsc.concept_id
WHERE nsc.note_id = n.id AND sc.primary_scheme_id != ALL(${}::uuid[])
)
)"#,
param_idx, param_idx
));
params.push(QueryParam::UuidArray(self.filter.required_schemes.clone()));
}
// Excluded schemes: must NOT have concepts from these schemes
if !self.filter.excluded_schemes.is_empty() {
param_idx += 1;
clauses.push(format!(
"NOT EXISTS (SELECT 1 FROM note_skos_concept nsc JOIN skos_concept sc ON sc.id = nsc.concept_id WHERE nsc.note_id = n.id AND sc.primary_scheme_id = ANY(${}::uuid[]))",
param_idx
));
params.push(QueryParam::UuidArray(self.filter.excluded_schemes.clone()));
}
// Minimum tag count
if let Some(min_count) = self.filter.min_tag_count {
param_idx += 1;
clauses.push(format!(
"(SELECT COUNT(*) FROM note_skos_concept nsc WHERE nsc.note_id = n.id) >= ${}",
param_idx
));
params.push(QueryParam::Int(min_count));
}
// Untagged notes handling
if !self.filter.include_untagged && !clauses.is_empty() {
// Already covered by required/any conditions
} else if !self.filter.include_untagged {
clauses.push(
"EXISTS (SELECT 1 FROM note_skos_concept nsc WHERE nsc.note_id = n.id)".to_string()
);
}
let sql = if clauses.is_empty() {
"TRUE".to_string()
} else {
clauses.join(" AND ")
};
(sql, params)
}
}
4.4 Updated Search Implementation
`matric-db/src/search.rs`
impl PgFtsSearch {
/// Search with strict tag filtering.
pub async fn search_with_strict_filter(
&self,
query: &str,
strict_filter: Option<&StrictTagFilter>,
limit: i64,
exclude_archived: bool,
) -> Result<Vec<SearchHit>> {
let archive_clause = if exclude_archived {
"AND (n.archived IS FALSE OR n.archived IS NULL) AND n.deleted_at IS NULL"
} else {
"AND n.deleted_at IS NULL"
};
// Build strict filter clause
let (filter_clause, filter_params) = if let Some(filter) = strict_filter {
if !filter.is_empty() {
let builder = StrictFilterQueryBuilder::new(filter.clone(), 2); // offset after query, limit
let (clause, params) = builder.build();
(format!("AND {}", clause), params)
} else {
(String::new(), Vec::new())
}
} else {
(String::new(), Vec::new())
};
let sql = format!(
r#"
WITH filtered_notes AS (
SELECT n.id
FROM note n
WHERE n.deleted_at IS NULL
{archive}
{filter}
)
SELECT n.id as note_id,
ts_rank(nrc.tsv, plainto_tsquery('english', $1)) AS score,
substring(nrc.content for 200) AS snippet,
n.title,
COALESCE(
(SELECT string_agg(cl.value, ',')
FROM note_skos_concept nsc
JOIN skos_concept_label cl ON cl.concept_id = nsc.concept_id
WHERE nsc.note_id = n.id AND cl.label_type = 'pref_label'
), ''
) as tags
FROM filtered_notes fn
JOIN note n ON n.id = fn.id
JOIN note_revised_current nrc ON nrc.note_id = n.id
WHERE nrc.tsv @@ plainto_tsquery('english', $1)
ORDER BY score DESC
LIMIT $2
"#,
archive = archive_clause,
filter = filter_clause
);
// Execute with dynamic parameters
// ... parameter binding logic ...
}
}
4.5 Semantic Search Integration
The strict filter must also apply to semantic (vector) search:
impl Database {
/// Find similar notes within strict filter constraints.
pub async fn find_similar_with_filter(
&self,
embedding: &Vector,
strict_filter: Option<&StrictTagFilter>,
limit: i64,
exclude_archived: bool,
) -> Result<Vec<SearchHit>> {
// Build filter CTE
let (filter_cte, filter_params) = build_filter_cte(strict_filter);
let sql = format!(
r#"
{filter_cte}
SELECT
e.note_id,
1 - (e.embedding <=> $1) AS score,
NULL as snippet,
n.title,
COALESCE(
(SELECT string_agg(cl.value, ',')
FROM note_skos_concept nsc
JOIN skos_concept_label cl ON cl.concept_id = nsc.concept_id
WHERE nsc.note_id = n.id AND cl.label_type = 'pref_label'
), ''
) as tags
FROM note_embedding e
JOIN note n ON n.id = e.note_id
{filter_join}
WHERE (n.archived IS FALSE OR n.archived IS NULL)
AND n.deleted_at IS NULL
ORDER BY e.embedding <=> $1
LIMIT $2
"#,
filter_cte = filter_cte,
filter_join = if strict_filter.is_some() {
"JOIN filtered_notes fn ON fn.id = e.note_id"
} else {
""
}
);
// Execute...
}
}
4.6 Notation Resolution Service
`matric-api/src/services/tag_resolver.rs`
/// Resolves tag/scheme notations to UUIDs.
pub struct TagResolver {
db: Database,
cache: Arc<Mutex<LruCache<String, Uuid>>>,
}
impl TagResolver {
/// Resolve notation string to concept UUID.
/// Searches: notation, pref_label, alt_label
pub async fn resolve_concept(&self, notation: &str) -> Result<Option<Uuid>> {
// Check cache first
if let Some(id) = self.cache.lock().unwrap().get(notation) {
return Ok(Some(*id));
}
// Query database
let result = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT sc.id
FROM skos_concept sc
LEFT JOIN skos_concept_label cl ON cl.concept_id = sc.id
WHERE sc.notation = $1
OR cl.value ILIKE $1
LIMIT 1
"#
)
.bind(notation)
.fetch_optional(&self.db.pool)
.await?;
// Cache result
if let Some(id) = result {
self.cache.lock().unwrap().put(notation.to_string(), id);
}
Ok(result)
}
/// Resolve scheme notation to UUID.
pub async fn resolve_scheme(&self, notation: &str) -> Result<Option<Uuid>> {
sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM skos_concept_scheme WHERE notation = $1"
)
.bind(notation)
.fetch_optional(&self.db.pool)
.await
.map_err(Into::into)
}
/// Batch resolve concepts.
pub async fn resolve_concepts(&self, notations: &[String]) -> Result<Vec<(String, Uuid)>> {
// ... batch query implementation
}
/// Convert input filter to resolved filter.
pub async fn resolve_filter(&self, input: StrictTagFilterInput) -> Result<StrictTagFilter> {
let mut filter = StrictTagFilter::default();
for notation in &input.required_tags {
if let Some(id) = self.resolve_concept(notation).await? {
filter.required_concepts.push(id);
} else {
return Err(Error::NotFound(format!("Concept not found: {}", notation)));
}
}
for notation in &input.any_tags {
if let Some(id) = self.resolve_concept(notation).await? {
filter.any_concepts.push(id);
}
// Non-existent tags in 'any' are silently ignored
}
for notation in &input.excluded_tags {
if let Some(id) = self.resolve_concept(notation).await? {
filter.excluded_concepts.push(id);
}
}
for notation in &input.required_schemes {
if let Some(id) = self.resolve_scheme(notation).await? {
filter.required_schemes.push(id);
} else {
return Err(Error::NotFound(format!("Scheme not found: {}", notation)));
}
}
for notation in &input.excluded_schemes {
if let Some(id) = self.resolve_scheme(notation).await? {
filter.excluded_schemes.push(id);
}
}
filter.min_tag_count = input.min_tag_count;
filter.include_untagged = input.include_untagged;
Ok(filter)
}
}
4.7 API Endpoint Updates
Updated Search Request Schema
# openapi.yaml additions
components:
schemas:
StrictTagFilter:
type: object
properties:
required_tags:
type: array
items:
type: string
description: Tags that notes MUST have (AND logic)
example: ["project:matric", "status:active"]
any_tags:
type: array
items:
type: string
description: Tags where notes must have AT LEAST ONE (OR logic)
example: ["priority:high", "priority:urgent"]
excluded_tags:
type: array
items:
type: string
description: Tags that notes MUST NOT have
example: ["status:archived", "internal"]
required_schemes:
type: array
items:
type: string
description: Scheme notations - notes must ONLY have tags from these schemes
example: ["client-acme"]
excluded_schemes:
type: array
items:
type: string
description: Scheme notations - notes must NOT have tags from these schemes
example: ["internal", "draft"]
min_tag_count:
type: integer
minimum: 0
description: Minimum number of tags required
include_untagged:
type: boolean
default: true
description: Whether to include notes with no tags
SearchNotesRequest:
type: object
properties:
query:
type: string
description: Search query text
mode:
type: string
enum: [hybrid, fts, semantic]
default: hybrid
limit:
type: integer
default: 20
strict_filter:
$ref: '#/components/schemas/StrictTagFilter'
Handler Update
// handlers.rs
#[derive(Debug, Deserialize)]
pub struct SearchNotesRequest {
query: String,
#[serde(default)]
mode: SearchMode,
#[serde(default = "default_limit")]
limit: i64,
#[serde(default)]
strict_filter: Option<StrictTagFilterInput>,
}
pub async fn search_notes(
State(ctx): State<AppContext>,
Query(req): Query<SearchNotesRequest>,
) -> Result<Json<SearchNotesResponse>, AppError> {
// Resolve filter if provided
let strict_filter = if let Some(input) = req.strict_filter {
Some(ctx.tag_resolver.resolve_filter(input).await?)
} else {
None
};
// Build config with strict filter
let config = HybridSearchConfig::default()
.with_strict_filter(strict_filter.unwrap_or_default());
// Execute search
let results = ctx.search_engine
.search(&req.query, None, req.limit, &config)
.await?;
Ok(Json(SearchNotesResponse { results }))
}
4.8 MCP Server Updates
New Tool: `search_notes_strict`
// mcp-server/index.js
{
name: "search_notes_strict",
description: `Search notes with guaranteed tag filtering.
Unlike fuzzy search, strict filtering guarantees results match criteria exactly.
Use this when data isolation is critical (e.g., client-specific searches).
Filter logic:
- required_tags: Notes MUST have ALL these tags (AND)
- any_tags: Notes MUST have AT LEAST ONE of these (OR)
- excluded_tags: Notes MUST NOT have ANY of these
- required_schemes: Notes ONLY from these vocabulary schemes
- excluded_schemes: Notes NOT from these schemes
Examples:
- Client isolation: required_schemes: ["client-acme"]
- Project + priority: required_tags: ["project:matric"], any_tags: ["priority:high", "priority:critical"]
- Exclude drafts: excluded_tags: ["status:draft", "internal"]`,
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (optional if only filtering)"
},
required_tags: {
type: "array",
items: { type: "string" },
description: "Tags notes MUST have (AND logic)"
},
any_tags: {
type: "array",
items: { type: "string" },
description: "Tags where notes must have at least one (OR logic)"
},
excluded_tags: {
type: "array",
items: { type: "string" },
description: "Tags notes must NOT have"
},
required_schemes: {
type: "array",
items: { type: "string" },
description: "Scheme notations for isolation"
},
excluded_schemes: {
type: "array",
items: { type: "string" },
description: "Schemes to exclude"
},
mode: {
type: "string",
enum: ["hybrid", "fts", "semantic"],
default: "hybrid"
},
limit: {
type: "number",
default: 20
}
}
}
}
Updated `search_notes` Tool
Add strict filter as optional parameters to existing tool:
{
name: "search_notes",
// ... existing description ...
inputSchema: {
// ... existing properties ...
properties: {
// ... existing ...
strict_filter: {
type: "object",
description: "Optional strict tag filter (guarantees results match)",
properties: {
required_tags: { type: "array", items: { type: "string" } },
any_tags: { type: "array", items: { type: "string" } },
excluded_tags: { type: "array", items: { type: "string" } },
required_schemes: { type: "array", items: { type: "string" } },
excluded_schemes: { type: "array", items: { type: "string" } }
}
}
}
}
}
5. Database Indexes
Add indexes to support efficient filtering:
-- Migration: 20260124000000_strict_filter_indexes.sql
-- Composite index for concept lookups by note
CREATE INDEX IF NOT EXISTS idx_note_skos_concept_note_concept
ON note_skos_concept(note_id, concept_id);
-- Index for scheme-based filtering
CREATE INDEX IF NOT EXISTS idx_skos_concept_scheme
ON skos_concept(primary_scheme_id);
-- Partial index for active concepts only
CREATE INDEX IF NOT EXISTS idx_skos_concept_active_scheme
ON skos_concept(primary_scheme_id)
WHERE status IN ('candidate', 'approved');
-- Covering index for label resolution
CREATE INDEX IF NOT EXISTS idx_skos_concept_label_lookup
ON skos_concept_label(concept_id, label_type, value)
WHERE label_type = 'pref_label';
-- Notation lookup index
CREATE INDEX IF NOT EXISTS idx_skos_concept_notation
ON skos_concept(notation)
WHERE notation IS NOT NULL;
-- Scheme notation lookup
CREATE INDEX IF NOT EXISTS idx_skos_scheme_notation
ON skos_concept_scheme(notation);
6. Testing Strategy
6.1 Unit Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strict_filter_builder_empty() {
let filter = StrictTagFilter::default();
assert!(filter.is_empty());
let builder = StrictFilterQueryBuilder::new(filter, 0);
let (sql, params) = builder.build();
assert_eq!(sql, "TRUE");
assert!(params.is_empty());
}
#[test]
fn test_strict_filter_required_concepts() {
let filter = StrictTagFilter::new()
.require_concept(Uuid::new_v4())
.require_concept(Uuid::new_v4());
let builder = StrictFilterQueryBuilder::new(filter, 2);
let (sql, params) = builder.build();
assert!(sql.contains("EXISTS"));
assert_eq!(params.len(), 2);
}
#[test]
fn test_strict_filter_any_concepts() {
let filter = StrictTagFilter {
any_concepts: vec![Uuid::new_v4(), Uuid::new_v4()],
..Default::default()
};
let builder = StrictFilterQueryBuilder::new(filter, 0);
let (sql, params) = builder.build();
assert!(sql.contains("ANY"));
assert_eq!(params.len(), 1); // Array param
}
#[test]
fn test_strict_filter_scheme_isolation() {
let filter = StrictTagFilter {
required_schemes: vec![Uuid::new_v4()],
..Default::default()
};
let builder = StrictFilterQueryBuilder::new(filter, 0);
let (sql, params) = builder.build();
assert!(sql.contains("primary_scheme_id"));
assert!(sql.contains("NOT EXISTS")); // Exclusion of other schemes
}
}
6.2 Integration Tests
#[tokio::test]
async fn test_search_with_required_tag() {
let ctx = setup_test_context().await;
// Create notes with different tags
let note1 = create_note_with_tags(&ctx, &["project:alpha"]).await;
let note2 = create_note_with_tags(&ctx, &["project:beta"]).await;
let note3 = create_note_with_tags(&ctx, &["project:alpha", "priority:high"]).await;
// Search with strict filter
let filter = StrictTagFilter::new()
.require_concept(resolve_concept(&ctx, "project:alpha").await);
let results = ctx.search_engine
.search_with_strict_filter("", Some(&filter), 100)
.await
.unwrap();
// Only notes with project:alpha
assert_eq!(results.len(), 2);
assert!(results.iter().any(|r| r.note_id == note1));
assert!(results.iter().any(|r| r.note_id == note3));
assert!(!results.iter().any(|r| r.note_id == note2));
}
#[tokio::test]
async fn test_scheme_isolation() {
let ctx = setup_test_context().await;
// Create scheme for client
let client_scheme = create_scheme(&ctx, "client-acme").await;
// Create notes in different schemes
let note1 = create_note_in_scheme(&ctx, client_scheme, &["topic:sales"]).await;
let note2 = create_note_in_scheme(&ctx, default_scheme, &["topic:internal"]).await;
// Search with scheme isolation
let filter = StrictTagFilter {
required_schemes: vec![client_scheme],
..Default::default()
};
let results = ctx.search_engine
.search_with_strict_filter("", Some(&filter), 100)
.await
.unwrap();
// Only notes from client scheme
assert_eq!(results.len(), 1);
assert_eq!(results[0].note_id, note1);
}
7. Performance Considerations
7.1 Query Plan Analysis
For typical queries, EXPLAIN ANALYZE shows:
- Empty filter: No additional overhead
- Single required concept: ~1ms additional with index
- Multiple required concepts: ~0.5ms per concept
- Scheme isolation: ~2ms with covering index
7.2 Optimization Strategies
1. CTE Materialization: Use `MATERIALIZED` hint for filter CTEs 2. Index-Only Scans: Covering indexes avoid heap lookups 3. Parallel Execution: Enable parallel workers for large result sets 4. Caching: LRU cache for notation resolution (TTL: 5 min)
7.3 Monitoring
Add metrics:
- `search_strict_filter_time_ms`: Time spent in filter evaluation
- `search_strict_filter_rows_scanned`: Rows examined by filter
- `tag_resolver_cache_hits`: Cache hit rate
8. Migration Path
Phase 1: Core Implementation
- Add `StrictTagFilter` types
- Implement query builder
- Add database indexes
Phase 2: API Integration
- Update search handlers
- Add notation resolution
- Update OpenAPI spec
Phase 3: MCP Server
- Add strict filter to search_notes
- Add search_notes_strict tool
- Update documentation
Phase 4: Testing & Optimization
- Integration tests
- Performance benchmarks
- Query plan optimization
9. Future Extensions
- Row-Level Security: Use strict filters as basis for RLS policies
- Filter Templates: Saved filter presets for common use cases
- Audit Logging: Log filter criteria for compliance
- Filter Inheritance: Child collections inherit parent filters