footprinter-cli 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +431 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
  19. footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
  20. footprinter/bundled/samples/visible-file-sample.txt +2 -0
  21. footprinter/cli/__init__.py +135 -0
  22. footprinter/cli/__main__.py +6 -0
  23. footprinter/cli/_common.py +327 -0
  24. footprinter/cli/_policy_helpers.py +646 -0
  25. footprinter/cli/_prompt.py +220 -0
  26. footprinter/cli/_sample_seed.py +204 -0
  27. footprinter/cli/api_cmd.py +32 -0
  28. footprinter/cli/connect.py +591 -0
  29. footprinter/cli/data.py +879 -0
  30. footprinter/cli/delete.py +128 -0
  31. footprinter/cli/ingest.py +543 -0
  32. footprinter/cli/mcp_cmd.py +750 -0
  33. footprinter/cli/mcp_setup.py +306 -0
  34. footprinter/cli/search.py +393 -0
  35. footprinter/cli/search_cmd.py +69 -0
  36. footprinter/cli/setup.py +2001 -0
  37. footprinter/cli/status.py +747 -0
  38. footprinter/cli/status_cmd.py +104 -0
  39. footprinter/cli/upsert.py +794 -0
  40. footprinter/cli/vectorize_cmd.py +215 -0
  41. footprinter/cli/view.py +322 -0
  42. footprinter/connectors/__init__.py +171 -0
  43. footprinter/connectors/config_utils.py +141 -0
  44. footprinter/db/__init__.py +37 -0
  45. footprinter/db/browser.py +198 -0
  46. footprinter/db/chats.py +602 -0
  47. footprinter/db/clients.py +307 -0
  48. footprinter/db/emails.py +279 -0
  49. footprinter/db/files.py +724 -0
  50. footprinter/db/folders.py +659 -0
  51. footprinter/db/messages.py +192 -0
  52. footprinter/db/policies.py +151 -0
  53. footprinter/db/projects.py +673 -0
  54. footprinter/db/search.py +573 -0
  55. footprinter/db/sql_utils.py +168 -0
  56. footprinter/db/status.py +320 -0
  57. footprinter/db/uploads.py +70 -0
  58. footprinter/ingest/__init__.py +0 -0
  59. footprinter/ingest/adapters/__init__.py +33 -0
  60. footprinter/ingest/adapters/browser.py +54 -0
  61. footprinter/ingest/adapters/chat.py +57 -0
  62. footprinter/ingest/adapters/ingest.py +146 -0
  63. footprinter/ingest/adapters/local_files.py +68 -0
  64. footprinter/ingest/adapters/local_folders.py +52 -0
  65. footprinter/ingest/adapters/protocol.py +174 -0
  66. footprinter/ingest/browser_indexer.py +216 -0
  67. footprinter/ingest/chat_dedup.py +156 -0
  68. footprinter/ingest/chat_indexer.py +487 -0
  69. footprinter/ingest/chat_parsers/__init__.py +8 -0
  70. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  71. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  72. footprinter/ingest/cli.py +827 -0
  73. footprinter/ingest/content_extractors.py +117 -0
  74. footprinter/ingest/database.py +36 -0
  75. footprinter/ingest/db/__init__.py +1 -0
  76. footprinter/ingest/db/connector_schema.py +47 -0
  77. footprinter/ingest/db/migration.py +315 -0
  78. footprinter/ingest/db/schema.py +1043 -0
  79. footprinter/ingest/db/security.py +6 -0
  80. footprinter/ingest/file_indexer.py +223 -0
  81. footprinter/ingest/file_scanner.py +277 -0
  82. footprinter/ingest/folder_indexer.py +226 -0
  83. footprinter/ingest/full_content_extractor.py +321 -0
  84. footprinter/ingest/orchestrator.py +112 -0
  85. footprinter/ingest/pipe_runner.py +200 -0
  86. footprinter/ingest/processing.py +165 -0
  87. footprinter/ingest/registry.py +186 -0
  88. footprinter/ingest/run_record.py +91 -0
  89. footprinter/ingest/status.py +346 -0
  90. footprinter/mcp/__init__.py +0 -0
  91. footprinter/mcp/__main__.py +5 -0
  92. footprinter/mcp/db.py +67 -0
  93. footprinter/mcp/errors.py +105 -0
  94. footprinter/mcp/extraction.py +226 -0
  95. footprinter/mcp/server.py +39 -0
  96. footprinter/mcp/tools/__init__.py +0 -0
  97. footprinter/mcp/tools/navigation.py +70 -0
  98. footprinter/mcp/tools/read.py +75 -0
  99. footprinter/mcp/tools/search.py +158 -0
  100. footprinter/mcp/tools/semantic.py +79 -0
  101. footprinter/mcp/tools/status.py +19 -0
  102. footprinter/paths.py +117 -0
  103. footprinter/permissions.py +1152 -0
  104. footprinter/semantic/__init__.py +13 -0
  105. footprinter/semantic/chunking.py +52 -0
  106. footprinter/semantic/embeddings.py +23 -0
  107. footprinter/semantic/hybrid_search.py +273 -0
  108. footprinter/semantic/vector_store.py +471 -0
  109. footprinter/services/__init__.py +49 -0
  110. footprinter/services/access_service.py +342 -0
  111. footprinter/services/chat_service.py +85 -0
  112. footprinter/services/client_service.py +267 -0
  113. footprinter/services/content_service.py +181 -0
  114. footprinter/services/email_service.py +89 -0
  115. footprinter/services/file_service.py +83 -0
  116. footprinter/services/folder_service.py +122 -0
  117. footprinter/services/includes.py +19 -0
  118. footprinter/services/ingest_service.py +231 -0
  119. footprinter/services/project_service.py +262 -0
  120. footprinter/services/roles.py +25 -0
  121. footprinter/services/search_service.py +177 -0
  122. footprinter/services/semantic_service.py +360 -0
  123. footprinter/services/status_service.py +18 -0
  124. footprinter/services/visit_service.py +65 -0
  125. footprinter/source_registry.py +194 -0
  126. footprinter/utils/__init__.py +7 -0
  127. footprinter/utils/hash_utils.py +59 -0
  128. footprinter/utils/logging_config.py +68 -0
  129. footprinter/utils/mime.py +30 -0
  130. footprinter/utils/text.py +6 -0
  131. footprinter/utils/time.py +11 -0
  132. footprinter/visibility.py +1264 -0
  133. footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
  134. footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
  135. footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
  136. footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  137. footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  138. footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1264 @@
1
+ """
2
+ Visibility resolution for Claude metadata access.
3
+
4
+ Two-tier model:
5
+ - Policies: Explicit rules (file:*, folder:*, source:*, project:*, client:*)
6
+ - Baseline: Hardcoded fallback (BASELINE_VISIBILITY = 'opaque')
7
+
8
+ Most-restrictive-wins semantics applies ONLY among matching policies.
9
+ hidden > opaque > visible
10
+
11
+ Hierarchy layers (checked for policies):
12
+ file:{id} → folder prefix → folder FK → project:{id} → client:{id} → source:*
13
+ email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
14
+ chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
15
+
16
+ Resolution:
17
+ 1. Collect explicit values from matching policies only
18
+ 2. If any policy is 'hidden' → return 'hidden'
19
+ 3. If any policy is 'opaque' → return 'opaque'
20
+ 4. If any policy is 'visible' → return 'visible'
21
+ 5. No policies matched → return BASELINE_VISIBILITY
22
+
23
+ Visibility states:
24
+ - 'hidden' - item doesn't exist to MCP (excluded from all results and counts)
25
+ - 'opaque' - appears with minimal info (id, content_type, source)
26
+ - 'visible' - full metadata returned
27
+
28
+ Hard Rule: Content read access can never exceed metadata visibility.
29
+ If an item is hidden or opaque, it cannot be read regardless of permission policy.
30
+ """
31
+
32
+ import os
33
+ import sqlite3
34
+ from typing import Dict, List, Literal, Optional, Tuple
35
+
36
+ from footprinter.db.policies import is_folder_path_scope
37
+ from footprinter.db.sql_utils import chunked_query as _chunked_query
38
+
39
+ VisibilityState = Literal["hidden", "opaque", "visible"]
40
+
41
+ # Hardcoded baseline - used when NO policies match
42
+ BASELINE_VISIBILITY: VisibilityState = "opaque"
43
+
44
+
45
+ def get_visibility(conn: sqlite3.Connection, item_type: str, item_id: int) -> VisibilityState:
46
+ """
47
+ Resolve visibility for an item.
48
+
49
+ Args:
50
+ conn: SQLite connection with row_factory = sqlite3.Row
51
+ item_type: 'file', 'email', 'chat', 'folder'
52
+ item_id: Row ID in the relevant table
53
+
54
+ Returns:
55
+ 'hidden', 'opaque', or 'visible'
56
+ """
57
+ cursor = conn.cursor()
58
+
59
+ if item_type == "file":
60
+ return _get_file_visibility(cursor, item_id)
61
+ elif item_type == "email":
62
+ return _get_email_visibility(cursor, item_id)
63
+ elif item_type == "chat":
64
+ return _get_chat_visibility(cursor, item_id)
65
+ elif item_type == "folder":
66
+ return _get_folder_visibility(cursor, item_id)
67
+ elif item_type == "visit":
68
+ return _get_browser_visibility(cursor, item_id)
69
+ elif item_type == "project":
70
+ resolved, _ = _resolve_project_visibility_with_source(cursor, item_id)
71
+ return resolved
72
+ elif item_type == "client":
73
+ resolved, _ = _resolve_client_visibility_with_source(cursor, item_id)
74
+ return resolved
75
+ else:
76
+ return BASELINE_VISIBILITY # Baseline for unknown types
77
+
78
+
79
+ def resolve_visibility_with_source(
80
+ conn: sqlite3.Connection, item_type: str, item_id: int
81
+ ) -> Tuple[VisibilityState, str]:
82
+ """
83
+ Resolve visibility and return the source that determined it.
84
+
85
+ Args:
86
+ conn: SQLite connection with row_factory = sqlite3.Row
87
+ item_type: 'file', 'email', 'chat', 'folder'
88
+ item_id: Row ID in the relevant table
89
+
90
+ Returns:
91
+ Tuple of (resolved_visibility, source_scope)
92
+ e.g., ('visible', "folder:~/Work") or ('opaque', "baseline")
93
+ """
94
+ cursor = conn.cursor()
95
+
96
+ if item_type == "file":
97
+ return _resolve_file_visibility_with_source(cursor, item_id)
98
+ elif item_type == "email":
99
+ return _resolve_email_visibility_with_source(cursor, item_id)
100
+ elif item_type == "chat":
101
+ return _resolve_chat_visibility_with_source(cursor, item_id)
102
+ elif item_type == "folder":
103
+ return _resolve_folder_visibility_with_source(cursor, item_id)
104
+ elif item_type == "project":
105
+ return _resolve_project_visibility_with_source(cursor, item_id)
106
+ elif item_type == "client":
107
+ return _resolve_client_visibility_with_source(cursor, item_id)
108
+ elif item_type == "visit":
109
+ return _resolve_browser_visibility_with_source(cursor, item_id)
110
+ else:
111
+ return (BASELINE_VISIBILITY, "baseline")
112
+
113
+
114
+ def batch_resolve_visibility(
115
+ conn: sqlite3.Connection, item_type: str, item_ids: List[int]
116
+ ) -> Dict[int, Tuple[VisibilityState, str]]:
117
+ """
118
+ Resolve visibility for multiple items efficiently.
119
+
120
+ Args:
121
+ conn: SQLite connection with row_factory = sqlite3.Row
122
+ item_type: 'file', 'email', 'chat', 'folder', 'project', 'client'
123
+ item_ids: List of row IDs
124
+
125
+ Returns:
126
+ Dict mapping item_id to (visibility_state, source) tuple
127
+ """
128
+ if not item_ids:
129
+ return {}
130
+
131
+ cursor = conn.cursor()
132
+
133
+ if item_type == "file":
134
+ return _batch_resolve_file_visibility(cursor, item_ids)
135
+ elif item_type == "project":
136
+ return _batch_resolve_project_visibility(cursor, item_ids)
137
+ elif item_type == "client":
138
+ return _batch_resolve_client_visibility(cursor, item_ids)
139
+ elif item_type == "email":
140
+ return _batch_resolve_email_visibility(cursor, item_ids)
141
+ elif item_type == "chat":
142
+ return _batch_resolve_chat_visibility(cursor, item_ids)
143
+ elif item_type == "folder":
144
+ return _batch_resolve_folder_visibility(cursor, item_ids)
145
+ elif item_type == "visit":
146
+ return _batch_resolve_browser_visibility(cursor, item_ids)
147
+ else:
148
+ return {id_: (BASELINE_VISIBILITY, "baseline") for id_ in item_ids}
149
+
150
+
151
+ def get_source_visibility(conn, scope: str) -> VisibilityState:
152
+ """Get the visibility state for a source scope (e.g. 'source:browser')."""
153
+ cursor = conn.cursor()
154
+ result = _get_policy(cursor, scope)
155
+ if result is not None:
156
+ return result
157
+ state, _ = _get_global_baseline(cursor)
158
+ return state
159
+
160
+
161
+ def _batch_resolve_file_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
162
+ """Batch resolve visibility for files."""
163
+ conn = cursor.connection
164
+
165
+ # Pre-fetch all visibility policies
166
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
167
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
168
+
169
+ # Global policy fallback
170
+ if "global" in all_policies:
171
+ global_baseline = (_resolve(all_policies["global"]), "global")
172
+ else:
173
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
174
+
175
+ # Pre-fetch folder policies sorted by length for prefix matching
176
+ # Exclude numeric-only suffixes (folder:{id}) — those are item-level scopes, not paths
177
+ folder_policies = [
178
+ (scope, setting)
179
+ for scope, setting in all_policies.items()
180
+ if scope.startswith("folder:") and is_folder_path_scope(scope)
181
+ ]
182
+ folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
183
+
184
+ # Pre-fetch file data (chunked to stay under SQLite variable limit)
185
+ rows = _chunked_query(
186
+ cursor,
187
+ """
188
+ SELECT file.id, file.path, file.project_id, file.folder_id, project.client_id
189
+ FROM files file
190
+ LEFT JOIN projects project ON file.project_id = project.id
191
+ WHERE file.id IN ({placeholders})
192
+ """,
193
+ item_ids,
194
+ )
195
+ files = {row["id"]: row for row in rows}
196
+
197
+ # Collect unique parent entity IDs for batch resolution
198
+ folder_ids = set()
199
+ project_ids = set()
200
+ client_ids = set()
201
+ for row in files.values():
202
+ if row["folder_id"]:
203
+ folder_ids.add(row["folder_id"])
204
+ if row["project_id"]:
205
+ project_ids.add(row["project_id"])
206
+ if row["client_id"]:
207
+ client_ids.add(row["client_id"])
208
+
209
+ # Batch resolve parent entities
210
+ folder_visibility = batch_resolve_visibility(conn, "folder", list(folder_ids)) if folder_ids else {}
211
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
212
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
213
+
214
+ results = {}
215
+ for file_id in item_ids:
216
+ if file_id not in files:
217
+ results[file_id] = (BASELINE_VISIBILITY, "not_found")
218
+ continue
219
+
220
+ row = files[file_id]
221
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
222
+
223
+ # 1. Item-level policy
224
+ item_scope = f"file:{file_id}"
225
+ if item_scope in all_policies:
226
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
227
+
228
+ # 2. Folder prefix match (most specific first)
229
+ path = row["path"] or ""
230
+ if path:
231
+ for scope, setting in folder_policies:
232
+ prefix = scope[len("folder:") :]
233
+ if prefix.startswith("~"):
234
+ prefix = os.path.expanduser(prefix)
235
+ if path.startswith(prefix):
236
+ policies.append((_resolve(setting), scope))
237
+ break
238
+
239
+ # 3. Folder FK via full resolution (skip baseline)
240
+ folder_id = row["folder_id"]
241
+ if folder_id and folder_id in folder_visibility:
242
+ state, src = folder_visibility[folder_id]
243
+ if src != "baseline":
244
+ policies.append((state, f"folder:{folder_id} (via {src})"))
245
+
246
+ # 4. Project-level via full resolution (skip baseline)
247
+ project_id = row["project_id"]
248
+ if project_id and project_id in project_visibility:
249
+ state, src = project_visibility[project_id]
250
+ if src != "baseline":
251
+ policies.append((state, f"project:{project_id} (via {src})"))
252
+
253
+ # 5. Client-level via full resolution (skip baseline)
254
+ client_id = row["client_id"]
255
+ if client_id and client_id in client_visibility:
256
+ state, src = client_visibility[client_id]
257
+ if src != "baseline":
258
+ policies.append((state, f"client:{client_id} (via {src})"))
259
+
260
+ # 6. Source policy
261
+ source_scope = "source:files"
262
+ if source_scope in all_policies:
263
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
264
+
265
+ # Resolve: most restrictive wins (hidden > opaque > visible)
266
+ for value, source in policies:
267
+ if value == "hidden":
268
+ results[file_id] = ("hidden", source)
269
+ break
270
+ else:
271
+ for value, source in policies:
272
+ if value == "opaque":
273
+ results[file_id] = ("opaque", source)
274
+ break
275
+ else:
276
+ for value, source in policies:
277
+ if value == "visible":
278
+ results[file_id] = ("visible", source)
279
+ break
280
+ else:
281
+ results[file_id] = global_baseline
282
+
283
+ return results
284
+
285
+
286
+ def _batch_resolve_project_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
287
+ """Batch resolve visibility for projects."""
288
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
289
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
290
+
291
+ # Global policy fallback
292
+ if "global" in all_policies:
293
+ global_baseline = (_resolve(all_policies["global"]), "global")
294
+ else:
295
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
296
+
297
+ # Pre-fetch project data for client_id (chunked)
298
+ rows = _chunked_query(
299
+ cursor,
300
+ "SELECT id, client_id FROM projects WHERE id IN ({placeholders})",
301
+ item_ids,
302
+ )
303
+ projects = {row["id"]: row for row in rows}
304
+
305
+ results = {}
306
+ for project_id in item_ids:
307
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
308
+
309
+ # 1. Project-level policy
310
+ proj_scope = f"project:{project_id}"
311
+ if proj_scope in all_policies:
312
+ policies.append((_resolve(all_policies[proj_scope]), proj_scope))
313
+
314
+ # 2. Client-level policy
315
+ if project_id in projects and projects[project_id]["client_id"]:
316
+ client_scope = f"client:{projects[project_id]['client_id']}"
317
+ if client_scope in all_policies:
318
+ policies.append((_resolve(all_policies[client_scope]), client_scope))
319
+
320
+ # 3. Source policy for projects
321
+ source_scope = "source:projects"
322
+ if source_scope in all_policies:
323
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
324
+
325
+ # Resolve: most restrictive wins
326
+ for value, source in policies:
327
+ if value == "hidden":
328
+ results[project_id] = ("hidden", source)
329
+ break
330
+ else:
331
+ for value, source in policies:
332
+ if value == "opaque":
333
+ results[project_id] = ("opaque", source)
334
+ break
335
+ else:
336
+ for value, source in policies:
337
+ if value == "visible":
338
+ results[project_id] = ("visible", source)
339
+ break
340
+ else:
341
+ results[project_id] = global_baseline
342
+
343
+ return results
344
+
345
+
346
+ def _batch_resolve_client_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
347
+ """Batch resolve visibility for clients."""
348
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
349
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
350
+
351
+ # Global policy fallback
352
+ if "global" in all_policies:
353
+ global_baseline = (_resolve(all_policies["global"]), "global")
354
+ else:
355
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
356
+
357
+ results = {}
358
+ for client_id in item_ids:
359
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
360
+
361
+ # 1. Client-level policy
362
+ client_scope = f"client:{client_id}"
363
+ if client_scope in all_policies:
364
+ policies.append((_resolve(all_policies[client_scope]), client_scope))
365
+
366
+ # 2. Source policy for clients
367
+ source_scope = "source:clients"
368
+ if source_scope in all_policies:
369
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
370
+
371
+ # Resolve: most restrictive wins
372
+ for value, source in policies:
373
+ if value == "hidden":
374
+ results[client_id] = ("hidden", source)
375
+ break
376
+ else:
377
+ for value, source in policies:
378
+ if value == "opaque":
379
+ results[client_id] = ("opaque", source)
380
+ break
381
+ else:
382
+ for value, source in policies:
383
+ if value == "visible":
384
+ results[client_id] = ("visible", source)
385
+ break
386
+ else:
387
+ results[client_id] = global_baseline
388
+
389
+ return results
390
+
391
+
392
+ def _batch_resolve_email_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
393
+ """Batch resolve visibility for emails."""
394
+ conn = cursor.connection
395
+
396
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
397
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
398
+
399
+ # Global policy fallback
400
+ if "global" in all_policies:
401
+ global_baseline = (_resolve(all_policies["global"]), "global")
402
+ else:
403
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
404
+
405
+ rows = _chunked_query(
406
+ cursor,
407
+ """
408
+ SELECT email.id, email.account, email.project_id, project.client_id
409
+ FROM emails email
410
+ LEFT JOIN projects project ON email.project_id = project.id
411
+ WHERE email.id IN ({placeholders})
412
+ """,
413
+ item_ids,
414
+ )
415
+ emails = {row["id"]: row for row in rows}
416
+
417
+ # Collect unique parent entity IDs for batch resolution
418
+ project_ids = set()
419
+ client_ids = set()
420
+ for row in emails.values():
421
+ if row["project_id"]:
422
+ project_ids.add(row["project_id"])
423
+ if row["client_id"]:
424
+ client_ids.add(row["client_id"])
425
+
426
+ # Batch resolve parent entities
427
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
428
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
429
+
430
+ results = {}
431
+ for email_id in item_ids:
432
+ if email_id not in emails:
433
+ results[email_id] = (BASELINE_VISIBILITY, "not_found")
434
+ continue
435
+
436
+ row = emails[email_id]
437
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
438
+
439
+ # 1. Item-level policy
440
+ item_scope = f"email:{email_id}"
441
+ if item_scope in all_policies:
442
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
443
+
444
+ # 2. Project-level via full resolution (skip baseline)
445
+ project_id = row["project_id"]
446
+ if project_id and project_id in project_visibility:
447
+ state, src = project_visibility[project_id]
448
+ if src != "baseline":
449
+ policies.append((state, f"project:{project_id} (via {src})"))
450
+
451
+ # 3. Client-level via full resolution (skip baseline)
452
+ client_id = row["client_id"]
453
+ if client_id and client_id in client_visibility:
454
+ state, src = client_visibility[client_id]
455
+ if src != "baseline":
456
+ policies.append((state, f"client:{client_id} (via {src})"))
457
+
458
+ # 4. Account-level policy
459
+ account = row["account"] or ""
460
+ if account:
461
+ acct_scope = f"account:{account}"
462
+ if acct_scope in all_policies:
463
+ policies.append((_resolve(all_policies[acct_scope]), acct_scope))
464
+
465
+ # 5. Source policy
466
+ source_scope = "source:emails"
467
+ if source_scope in all_policies:
468
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
469
+
470
+ # Resolve: most restrictive wins
471
+ for value, source in policies:
472
+ if value == "hidden":
473
+ results[email_id] = ("hidden", source)
474
+ break
475
+ else:
476
+ for value, source in policies:
477
+ if value == "opaque":
478
+ results[email_id] = ("opaque", source)
479
+ break
480
+ else:
481
+ for value, source in policies:
482
+ if value == "visible":
483
+ results[email_id] = ("visible", source)
484
+ break
485
+ else:
486
+ results[email_id] = global_baseline
487
+
488
+ return results
489
+
490
+
491
+ def _batch_resolve_chat_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
492
+ """Batch resolve visibility for chats."""
493
+ conn = cursor.connection
494
+
495
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
496
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
497
+
498
+ # Global policy fallback
499
+ if "global" in all_policies:
500
+ global_baseline = (_resolve(all_policies["global"]), "global")
501
+ else:
502
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
503
+
504
+ rows = _chunked_query(
505
+ cursor,
506
+ """
507
+ SELECT chat.id, chat.account, chat.project_id, project.client_id
508
+ FROM chats chat
509
+ LEFT JOIN projects project ON chat.project_id = project.id
510
+ WHERE chat.id IN ({placeholders})
511
+ """,
512
+ item_ids,
513
+ )
514
+ convs = {row["id"]: row for row in rows}
515
+
516
+ # Collect unique parent entity IDs for batch resolution
517
+ project_ids = set()
518
+ client_ids = set()
519
+ for row in convs.values():
520
+ if row["project_id"]:
521
+ project_ids.add(row["project_id"])
522
+ if row["client_id"]:
523
+ client_ids.add(row["client_id"])
524
+
525
+ # Batch resolve parent entities
526
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
527
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
528
+
529
+ results = {}
530
+ for chat_id in item_ids:
531
+ if chat_id not in convs:
532
+ results[chat_id] = (BASELINE_VISIBILITY, "not_found")
533
+ continue
534
+
535
+ row = convs[chat_id]
536
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
537
+
538
+ # 1. Item-level policy
539
+ item_scope = f"chat:{chat_id}"
540
+ if item_scope in all_policies:
541
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
542
+
543
+ # 2. Project-level via full resolution (skip baseline)
544
+ project_id = row["project_id"]
545
+ if project_id and project_id in project_visibility:
546
+ state, src = project_visibility[project_id]
547
+ if src != "baseline":
548
+ policies.append((state, f"project:{project_id} (via {src})"))
549
+
550
+ # 3. Client-level via full resolution (skip baseline)
551
+ client_id = row["client_id"]
552
+ if client_id and client_id in client_visibility:
553
+ state, src = client_visibility[client_id]
554
+ if src != "baseline":
555
+ policies.append((state, f"client:{client_id} (via {src})"))
556
+
557
+ # 4. Account-level policy
558
+ account = row["account"] or ""
559
+ if account:
560
+ acct_scope = f"account:{account}"
561
+ if acct_scope in all_policies:
562
+ policies.append((_resolve(all_policies[acct_scope]), acct_scope))
563
+
564
+ # 5. Source policy
565
+ source_scope = "source:chats"
566
+ if source_scope in all_policies:
567
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
568
+
569
+ # Resolve: most restrictive wins
570
+ for value, source in policies:
571
+ if value == "hidden":
572
+ results[chat_id] = ("hidden", source)
573
+ break
574
+ else:
575
+ for value, source in policies:
576
+ if value == "opaque":
577
+ results[chat_id] = ("opaque", source)
578
+ break
579
+ else:
580
+ for value, source in policies:
581
+ if value == "visible":
582
+ results[chat_id] = ("visible", source)
583
+ break
584
+ else:
585
+ results[chat_id] = global_baseline
586
+
587
+ return results
588
+
589
+
590
+ def _batch_resolve_folder_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
591
+ """Batch resolve visibility for folders."""
592
+ conn = cursor.connection
593
+
594
+ cursor.execute("SELECT scope, setting FROM visibility_policies")
595
+ all_policies = {row["scope"]: row["setting"] for row in cursor.fetchall()}
596
+
597
+ # Global policy fallback
598
+ if "global" in all_policies:
599
+ global_baseline = (_resolve(all_policies["global"]), "global")
600
+ else:
601
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
602
+
603
+ folder_policies = [
604
+ (scope, setting)
605
+ for scope, setting in all_policies.items()
606
+ if scope.startswith("folder:") and is_folder_path_scope(scope)
607
+ ]
608
+ folder_policies.sort(key=lambda x: len(x[0]), reverse=True)
609
+
610
+ rows = _chunked_query(
611
+ cursor,
612
+ """
613
+ SELECT folder.id, folder.path, folder.project_id, project.client_id
614
+ FROM folders folder
615
+ LEFT JOIN projects project ON folder.project_id = project.id
616
+ WHERE folder.id IN ({placeholders})
617
+ """,
618
+ item_ids,
619
+ )
620
+ folders = {row["id"]: row for row in rows}
621
+
622
+ # Collect unique parent entity IDs for batch resolution
623
+ project_ids = set()
624
+ client_ids = set()
625
+ for row in folders.values():
626
+ if row["project_id"]:
627
+ project_ids.add(row["project_id"])
628
+ if row["client_id"]:
629
+ client_ids.add(row["client_id"])
630
+
631
+ # Batch resolve parent entities
632
+ project_visibility = batch_resolve_visibility(conn, "project", list(project_ids)) if project_ids else {}
633
+ client_visibility = batch_resolve_visibility(conn, "client", list(client_ids)) if client_ids else {}
634
+
635
+ results = {}
636
+ for folder_id in item_ids:
637
+ if folder_id not in folders:
638
+ results[folder_id] = (BASELINE_VISIBILITY, "not_found")
639
+ continue
640
+
641
+ row = folders[folder_id]
642
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
643
+
644
+ # 1. Item-level policy
645
+ item_scope = f"folder:{folder_id}"
646
+ if item_scope in all_policies:
647
+ policies.append((_resolve(all_policies[item_scope]), item_scope))
648
+
649
+ # 2. Folder prefix match
650
+ path = row["path"] or ""
651
+ if path:
652
+ for scope, setting in folder_policies:
653
+ prefix = scope[len("folder:") :]
654
+ if prefix.startswith("~"):
655
+ prefix = os.path.expanduser(prefix)
656
+ if path.startswith(prefix):
657
+ policies.append((_resolve(setting), scope))
658
+ break
659
+
660
+ # 3. Project-level via full resolution (skip baseline)
661
+ project_id = row["project_id"]
662
+ if project_id and project_id in project_visibility:
663
+ state, src = project_visibility[project_id]
664
+ if src != "baseline":
665
+ policies.append((state, f"project:{project_id} (via {src})"))
666
+
667
+ # 4. Client-level via full resolution (skip baseline)
668
+ client_id = row["client_id"]
669
+ if client_id and client_id in client_visibility:
670
+ state, src = client_visibility[client_id]
671
+ if src != "baseline":
672
+ policies.append((state, f"client:{client_id} (via {src})"))
673
+
674
+ # 5. Source policy
675
+ source_scope = "source:folders"
676
+ if source_scope in all_policies:
677
+ policies.append((_resolve(all_policies[source_scope]), source_scope))
678
+
679
+ # Resolve: most restrictive wins
680
+ for value, source in policies:
681
+ if value == "hidden":
682
+ results[folder_id] = ("hidden", source)
683
+ break
684
+ else:
685
+ for value, source in policies:
686
+ if value == "opaque":
687
+ results[folder_id] = ("opaque", source)
688
+ break
689
+ else:
690
+ for value, source in policies:
691
+ if value == "visible":
692
+ results[folder_id] = ("visible", source)
693
+ break
694
+ else:
695
+ results[folder_id] = global_baseline
696
+
697
+ return results
698
+
699
+
700
+ def _resolve(value: Optional[str]) -> Optional[VisibilityState]:
701
+ """Convert a visibility value to state or None (no policy)."""
702
+ if value in ("hidden", "opaque", "visible"):
703
+ return value
704
+ return None # 'inherit' or NULL means no policy
705
+
706
+
707
+ def _get_policy(cursor, scope: str) -> Optional[VisibilityState]:
708
+ """Look up a visibility_policies row."""
709
+ cursor.execute("SELECT setting FROM visibility_policies WHERE scope = ?", (scope,))
710
+ row = cursor.fetchone()
711
+ if row:
712
+ return _resolve(row["setting"])
713
+ return None
714
+
715
+
716
+ def _get_global_baseline(cursor) -> Tuple[VisibilityState, str]:
717
+ """Get global policy or fall back to hardcoded baseline."""
718
+ row = cursor.execute("SELECT setting FROM visibility_policies WHERE scope = 'global'").fetchone()
719
+ if row:
720
+ return (_resolve(row["setting"]), "global")
721
+ return (BASELINE_VISIBILITY, "baseline")
722
+
723
+
724
+ def _resolve_parent_with_source(
725
+ conn: sqlite3.Connection, item_type: str, item_id: int
726
+ ) -> Optional[Tuple[VisibilityState, str]]:
727
+ """Resolve parent entity visibility, returning None if baseline.
728
+
729
+ This is used when resolving file/folder visibility to check parent
730
+ entities (folder, project, client). If the parent resolves to baseline,
731
+ we return None so that baseline doesn't propagate down the hierarchy.
732
+ """
733
+ state, source = resolve_visibility_with_source(conn, item_type, item_id)
734
+ if source == "baseline":
735
+ return None
736
+ return (state, source)
737
+
738
+
739
+ def _get_file_visibility(cursor, file_id: int) -> VisibilityState:
740
+ """Resolve file visibility using policies."""
741
+ resolved, _ = _resolve_file_visibility_with_source(cursor, file_id)
742
+ return resolved
743
+
744
+
745
+ def _resolve_file_visibility_with_source(cursor, file_id: int) -> Tuple[VisibilityState, str]:
746
+ """Resolve file visibility with source tracking."""
747
+ cursor.execute(
748
+ """
749
+ SELECT file.path, file.project_id, file.folder_id, project.client_id
750
+ FROM files file
751
+ LEFT JOIN projects project ON file.project_id = project.id
752
+ WHERE file.id = ?
753
+ """,
754
+ (file_id,),
755
+ )
756
+ row = cursor.fetchone()
757
+ if not row:
758
+ return (BASELINE_VISIBILITY, "not_found")
759
+
760
+ # Collect matching policies only (not baseline)
761
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
762
+
763
+ # 1. Item-level policy (file:{id})
764
+ item_policy = _get_policy(cursor, f"file:{file_id}")
765
+ if item_policy is not None:
766
+ policies.append((item_policy, f"file:{file_id}"))
767
+
768
+ # 2. Folder prefix match (most specific first)
769
+ path = row["path"] or ""
770
+ if path:
771
+ cursor.execute(
772
+ """
773
+ SELECT scope, setting FROM visibility_policies
774
+ WHERE scope LIKE 'folder:%'
775
+ ORDER BY LENGTH(scope) DESC
776
+ """
777
+ )
778
+ for folder_row in cursor.fetchall():
779
+ if not is_folder_path_scope(folder_row["scope"]):
780
+ continue
781
+ prefix = folder_row["scope"][len("folder:") :]
782
+ if prefix.startswith("~"):
783
+ prefix = os.path.expanduser(prefix)
784
+ if path.startswith(prefix):
785
+ policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
786
+ break # Only use most specific folder match
787
+
788
+ # 3. Folder-level via full resolution (folder:{id}) - skip baseline
789
+ folder_id = row["folder_id"]
790
+ if folder_id:
791
+ conn = cursor.connection
792
+ result = _resolve_parent_with_source(conn, "folder", folder_id)
793
+ if result:
794
+ state, src = result
795
+ policies.append((state, f"folder:{folder_id} (via {src})"))
796
+
797
+ # 4. Project-level via full resolution (project:{id}) - skip baseline
798
+ project_id = row["project_id"]
799
+ if project_id:
800
+ conn = cursor.connection
801
+ result = _resolve_parent_with_source(conn, "project", project_id)
802
+ if result:
803
+ state, src = result
804
+ policies.append((state, f"project:{project_id} (via {src})"))
805
+
806
+ # 5. Client-level via full resolution (client:{id}) - skip baseline
807
+ client_id = row["client_id"]
808
+ if client_id:
809
+ conn = cursor.connection
810
+ result = _resolve_parent_with_source(conn, "client", client_id)
811
+ if result:
812
+ state, src = result
813
+ policies.append((state, f"client:{client_id} (via {src})"))
814
+
815
+ # 6. Source policy
816
+ source_policy = _get_policy(cursor, "source:files")
817
+ if source_policy is not None:
818
+ policies.append((source_policy, "source:files"))
819
+
820
+ # MOST-RESTRICTIVE-WINS among matching policies only:
821
+ # hidden > opaque > visible
822
+ for value, source in policies:
823
+ if value == "hidden":
824
+ return ("hidden", source)
825
+
826
+ for value, source in policies:
827
+ if value == "opaque":
828
+ return ("opaque", source)
829
+
830
+ for value, source in policies:
831
+ if value == "visible":
832
+ return ("visible", source)
833
+
834
+ # No policies matched → use global policy or baseline
835
+ return _get_global_baseline(cursor)
836
+
837
+
838
+ def _get_email_visibility(cursor, email_id: int) -> VisibilityState:
839
+ """Resolve visibility for an email using policies."""
840
+ resolved, _ = _resolve_email_visibility_with_source(cursor, email_id)
841
+ return resolved
842
+
843
+
844
+ def _resolve_email_visibility_with_source(cursor, email_id: int) -> Tuple[VisibilityState, str]:
845
+ """Resolve email visibility with source tracking.
846
+
847
+ Chain: email:{id} → project:{id} → client:{id} → account:{acct} → source:emails
848
+ """
849
+ cursor.execute(
850
+ """
851
+ SELECT email.account, email.project_id, project.client_id
852
+ FROM emails email
853
+ LEFT JOIN projects project ON email.project_id = project.id
854
+ WHERE email.id = ?
855
+ """,
856
+ (email_id,),
857
+ )
858
+ row = cursor.fetchone()
859
+ if not row:
860
+ return (BASELINE_VISIBILITY, "not_found")
861
+
862
+ # Collect matching policies only (not baseline)
863
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
864
+
865
+ # 1. Item-level policy (email:{id})
866
+ item_policy = _get_policy(cursor, f"email:{email_id}")
867
+ if item_policy is not None:
868
+ policies.append((item_policy, f"email:{email_id}"))
869
+
870
+ # 2. Project-level via full resolution (project:{id}) - skip baseline
871
+ project_id = row["project_id"]
872
+ if project_id:
873
+ conn = cursor.connection
874
+ result = _resolve_parent_with_source(conn, "project", project_id)
875
+ if result:
876
+ state, src = result
877
+ policies.append((state, f"project:{project_id} (via {src})"))
878
+
879
+ # 3. Client-level via full resolution (client:{id}) - skip baseline
880
+ client_id = row["client_id"]
881
+ if client_id:
882
+ conn = cursor.connection
883
+ result = _resolve_parent_with_source(conn, "client", client_id)
884
+ if result:
885
+ state, src = result
886
+ policies.append((state, f"client:{client_id} (via {src})"))
887
+
888
+ # 4. Account-level policy
889
+ account = row["account"] or ""
890
+ if account:
891
+ account_policy = _get_policy(cursor, f"account:{account}")
892
+ if account_policy is not None:
893
+ policies.append((account_policy, f"account:{account}"))
894
+
895
+ # 5. Source policy
896
+ source_policy = _get_policy(cursor, "source:emails")
897
+ if source_policy is not None:
898
+ policies.append((source_policy, "source:emails"))
899
+
900
+ # MOST-RESTRICTIVE-WINS among matching policies only:
901
+ # hidden > opaque > visible
902
+ for value, source in policies:
903
+ if value == "hidden":
904
+ return ("hidden", source)
905
+
906
+ for value, source in policies:
907
+ if value == "opaque":
908
+ return ("opaque", source)
909
+
910
+ for value, source in policies:
911
+ if value == "visible":
912
+ return ("visible", source)
913
+
914
+ # No policies matched → use global policy or baseline
915
+ return _get_global_baseline(cursor)
916
+
917
+
918
+ def _get_chat_visibility(cursor, chat_id: int) -> VisibilityState:
919
+ """Resolve visibility for a chat using policies."""
920
+ resolved, _ = _resolve_chat_visibility_with_source(cursor, chat_id)
921
+ return resolved
922
+
923
+
924
+ def _resolve_chat_visibility_with_source(cursor, chat_id: int) -> Tuple[VisibilityState, str]:
925
+ """Resolve chat visibility with source tracking.
926
+
927
+ Chain: chat:{id} → project:{id} → client:{id} → account:{acct} → source:chats
928
+ """
929
+ cursor.execute(
930
+ """
931
+ SELECT chat.account, chat.project_id, project.client_id
932
+ FROM chats chat
933
+ LEFT JOIN projects project ON chat.project_id = project.id
934
+ WHERE chat.id = ?
935
+ """,
936
+ (chat_id,),
937
+ )
938
+ row = cursor.fetchone()
939
+ if not row:
940
+ return (BASELINE_VISIBILITY, "not_found")
941
+
942
+ # Collect matching policies only (not baseline)
943
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
944
+
945
+ # 1. Item-level policy (chat:{id})
946
+ item_policy = _get_policy(cursor, f"chat:{chat_id}")
947
+ if item_policy is not None:
948
+ policies.append((item_policy, f"chat:{chat_id}"))
949
+
950
+ # 2. Project-level via full resolution (project:{id}) - skip baseline
951
+ project_id = row["project_id"]
952
+ if project_id:
953
+ conn = cursor.connection
954
+ result = _resolve_parent_with_source(conn, "project", project_id)
955
+ if result:
956
+ state, src = result
957
+ policies.append((state, f"project:{project_id} (via {src})"))
958
+
959
+ # 3. Client-level via full resolution (client:{id}) - skip baseline
960
+ client_id = row["client_id"]
961
+ if client_id:
962
+ conn = cursor.connection
963
+ result = _resolve_parent_with_source(conn, "client", client_id)
964
+ if result:
965
+ state, src = result
966
+ policies.append((state, f"client:{client_id} (via {src})"))
967
+
968
+ # 4. Account-level policy (e.g., account:claude)
969
+ account = row["account"] or ""
970
+ if account:
971
+ account_policy = _get_policy(cursor, f"account:{account}")
972
+ if account_policy is not None:
973
+ policies.append((account_policy, f"account:{account}"))
974
+
975
+ # 5. Source policy (source:chats)
976
+ source_policy = _get_policy(cursor, "source:chats")
977
+ if source_policy is not None:
978
+ policies.append((source_policy, "source:chats"))
979
+
980
+ # MOST-RESTRICTIVE-WINS among matching policies only:
981
+ # hidden > opaque > visible
982
+ for value, source in policies:
983
+ if value == "hidden":
984
+ return ("hidden", source)
985
+
986
+ for value, source in policies:
987
+ if value == "opaque":
988
+ return ("opaque", source)
989
+
990
+ for value, source in policies:
991
+ if value == "visible":
992
+ return ("visible", source)
993
+
994
+ # No policies matched → use global policy or baseline
995
+ return _get_global_baseline(cursor)
996
+
997
+
998
+ def _get_folder_visibility(cursor, folder_id: int) -> VisibilityState:
999
+ """Resolve visibility for an indexed folder using policies."""
1000
+ resolved, _ = _resolve_folder_visibility_with_source(cursor, folder_id)
1001
+ return resolved
1002
+
1003
+
1004
+ def _resolve_folder_visibility_with_source(cursor, folder_id: int) -> Tuple[VisibilityState, str]:
1005
+ """Resolve folder visibility with source tracking."""
1006
+ cursor.execute(
1007
+ """
1008
+ SELECT folder.path, folder.project_id, project.client_id
1009
+ FROM folders folder
1010
+ LEFT JOIN projects project ON folder.project_id = project.id
1011
+ WHERE folder.id = ?
1012
+ """,
1013
+ (folder_id,),
1014
+ )
1015
+ row = cursor.fetchone()
1016
+ if not row:
1017
+ return (BASELINE_VISIBILITY, "not_found")
1018
+
1019
+ # Collect matching policies only (not baseline)
1020
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
1021
+
1022
+ # 1. Item-level policy (folder:{id})
1023
+ item_policy = _get_policy(cursor, f"folder:{folder_id}")
1024
+ if item_policy is not None:
1025
+ policies.append((item_policy, f"folder:{folder_id}"))
1026
+
1027
+ # 2. Folder prefix match (most specific first)
1028
+ path = row["path"] or ""
1029
+ if path:
1030
+ cursor.execute(
1031
+ """
1032
+ SELECT scope, setting FROM visibility_policies
1033
+ WHERE scope LIKE 'folder:%'
1034
+ ORDER BY LENGTH(scope) DESC
1035
+ """
1036
+ )
1037
+ for folder_row in cursor.fetchall():
1038
+ if not is_folder_path_scope(folder_row["scope"]):
1039
+ continue
1040
+ prefix = folder_row["scope"][len("folder:") :]
1041
+ if prefix.startswith("~"):
1042
+ prefix = os.path.expanduser(prefix)
1043
+ if path.startswith(prefix):
1044
+ policies.append((_resolve(folder_row["setting"]), folder_row["scope"]))
1045
+ break # Only use most specific folder match
1046
+
1047
+ # 3. Project-level via full resolution (project:{id}) - skip baseline
1048
+ project_id = row["project_id"]
1049
+ if project_id:
1050
+ conn = cursor.connection
1051
+ result = _resolve_parent_with_source(conn, "project", project_id)
1052
+ if result:
1053
+ state, src = result
1054
+ policies.append((state, f"project:{project_id} (via {src})"))
1055
+
1056
+ # 4. Client-level via full resolution (client:{id}) - skip baseline
1057
+ client_id = row["client_id"]
1058
+ if client_id:
1059
+ conn = cursor.connection
1060
+ result = _resolve_parent_with_source(conn, "client", client_id)
1061
+ if result:
1062
+ state, src = result
1063
+ policies.append((state, f"client:{client_id} (via {src})"))
1064
+
1065
+ # 5. Source policy
1066
+ source_policy = _get_policy(cursor, "source:folders")
1067
+ if source_policy is not None:
1068
+ policies.append((source_policy, "source:folders"))
1069
+
1070
+ # MOST-RESTRICTIVE-WINS among matching policies only:
1071
+ # hidden > opaque > visible
1072
+ for value, source in policies:
1073
+ if value == "hidden":
1074
+ return ("hidden", source)
1075
+
1076
+ for value, source in policies:
1077
+ if value == "opaque":
1078
+ return ("opaque", source)
1079
+
1080
+ for value, source in policies:
1081
+ if value == "visible":
1082
+ return ("visible", source)
1083
+
1084
+ # No policies matched → use global policy or baseline
1085
+ return _get_global_baseline(cursor)
1086
+
1087
+
1088
+ def _resolve_project_visibility_with_source(cursor, project_id: int) -> Tuple[VisibilityState, str]:
1089
+ """Resolve project visibility with source tracking.
1090
+
1091
+ Chain: project:{id} -> client:{id} -> source:projects
1092
+ """
1093
+ cursor.execute(
1094
+ """
1095
+ SELECT client_id FROM projects WHERE id = ?
1096
+ """,
1097
+ (project_id,),
1098
+ )
1099
+ row = cursor.fetchone()
1100
+ if not row:
1101
+ return (BASELINE_VISIBILITY, "not_found")
1102
+
1103
+ # Collect matching policies only (not baseline)
1104
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
1105
+
1106
+ # 1. Project-level policy (project:{id})
1107
+ project_policy = _get_policy(cursor, f"project:{project_id}")
1108
+ if project_policy is not None:
1109
+ policies.append((project_policy, f"project:{project_id}"))
1110
+
1111
+ # 2. Client-level policy via full resolution (skip baseline)
1112
+ client_id = row["client_id"]
1113
+ if client_id:
1114
+ conn = cursor.connection
1115
+ result = _resolve_parent_with_source(conn, "client", client_id)
1116
+ if result:
1117
+ state, src = result
1118
+ policies.append((state, f"client:{client_id} (via {src})"))
1119
+
1120
+ # 3. Source policy for projects
1121
+ source_policy = _get_policy(cursor, "source:projects")
1122
+ if source_policy is not None:
1123
+ policies.append((source_policy, "source:projects"))
1124
+
1125
+ # MOST-RESTRICTIVE-WINS among matching policies only:
1126
+ # hidden > opaque > visible
1127
+ for value, source in policies:
1128
+ if value == "hidden":
1129
+ return ("hidden", source)
1130
+
1131
+ for value, source in policies:
1132
+ if value == "opaque":
1133
+ return ("opaque", source)
1134
+
1135
+ for value, source in policies:
1136
+ if value == "visible":
1137
+ return ("visible", source)
1138
+
1139
+ # No policies matched → use global policy or baseline
1140
+ return _get_global_baseline(cursor)
1141
+
1142
+
1143
+ def _resolve_client_visibility_with_source(cursor, client_id: int) -> Tuple[VisibilityState, str]:
1144
+ """Resolve client visibility with source tracking.
1145
+
1146
+ Chain: client:{id} -> source:clients
1147
+ """
1148
+ cursor.execute("SELECT id FROM clients WHERE id = ?", (client_id,))
1149
+ row = cursor.fetchone()
1150
+ if not row:
1151
+ return (BASELINE_VISIBILITY, "not_found")
1152
+
1153
+ # Collect matching policies only (not baseline)
1154
+ policies: List[Tuple[Optional[VisibilityState], str]] = []
1155
+
1156
+ # 1. Client-level policy (client:{id})
1157
+ client_policy = _get_policy(cursor, f"client:{client_id}")
1158
+ if client_policy is not None:
1159
+ policies.append((client_policy, f"client:{client_id}"))
1160
+
1161
+ # 2. Source policy for clients
1162
+ source_policy = _get_policy(cursor, "source:clients")
1163
+ if source_policy is not None:
1164
+ policies.append((source_policy, "source:clients"))
1165
+
1166
+ # MOST-RESTRICTIVE-WINS among matching policies only:
1167
+ # hidden > opaque > visible
1168
+ for value, source in policies:
1169
+ if value == "hidden":
1170
+ return ("hidden", source)
1171
+
1172
+ for value, source in policies:
1173
+ if value == "opaque":
1174
+ return ("opaque", source)
1175
+
1176
+ for value, source in policies:
1177
+ if value == "visible":
1178
+ return ("visible", source)
1179
+
1180
+ # No policies matched → use global policy or baseline
1181
+ return _get_global_baseline(cursor)
1182
+
1183
+
1184
+ def _get_browser_visibility(cursor, browser_id: int) -> VisibilityState:
1185
+ """Resolve visibility for a browser history item using policies.
1186
+
1187
+ Browser history only has source-level policies (no item/folder/project/client hierarchy).
1188
+ """
1189
+ resolved, _ = _resolve_browser_visibility_with_source(cursor, browser_id)
1190
+ return resolved
1191
+
1192
+
1193
+ def _resolve_browser_visibility_with_source(cursor, browser_id: int) -> Tuple[VisibilityState, str]:
1194
+ """Resolve browser history visibility with source tracking.
1195
+
1196
+ Browser history has no hierarchy - only source-level policy applies.
1197
+ Chain: source:browser → baseline
1198
+ """
1199
+ # Verify item exists
1200
+ cursor.execute("SELECT id FROM visits WHERE id = ?", (browser_id,))
1201
+ row = cursor.fetchone()
1202
+ if not row:
1203
+ return (BASELINE_VISIBILITY, "not_found")
1204
+
1205
+ # Only source policy applies
1206
+ source_policy = _get_policy(cursor, "source:browser")
1207
+ if source_policy is not None:
1208
+ return (source_policy, "source:browser")
1209
+
1210
+ # No policies matched → use global policy or baseline
1211
+ return _get_global_baseline(cursor)
1212
+
1213
+
1214
+ def _batch_resolve_browser_visibility(cursor, item_ids: List[int]) -> Dict[int, Tuple[VisibilityState, str]]:
1215
+ """Batch resolve visibility for browser history items.
1216
+
1217
+ Since browser history only uses source-level policy, we can resolve once
1218
+ and apply to all items.
1219
+ """
1220
+ cursor.execute("SELECT scope, setting FROM visibility_policies WHERE scope IN ('source:browser', 'global')")
1221
+ rows = {row["scope"]: row["setting"] for row in cursor.fetchall()}
1222
+
1223
+ if "source:browser" in rows:
1224
+ source_visibility = _resolve(rows["source:browser"])
1225
+ source = "source:browser"
1226
+ else:
1227
+ source_visibility = None
1228
+
1229
+ # Global policy fallback
1230
+ if "global" in rows:
1231
+ global_baseline = (_resolve(rows["global"]), "global")
1232
+ else:
1233
+ global_baseline = (BASELINE_VISIBILITY, "baseline")
1234
+
1235
+ # Verify which items exist (chunked)
1236
+ if item_ids:
1237
+ existing_rows = _chunked_query(
1238
+ cursor,
1239
+ "SELECT id FROM visits WHERE id IN ({placeholders})",
1240
+ item_ids,
1241
+ )
1242
+ existing_ids = {row["id"] for row in existing_rows}
1243
+ else:
1244
+ existing_ids = set()
1245
+
1246
+ results = {}
1247
+ for item_id in item_ids:
1248
+ if item_id not in existing_ids:
1249
+ results[item_id] = (BASELINE_VISIBILITY, "not_found")
1250
+ elif source_visibility is not None:
1251
+ results[item_id] = (source_visibility, source)
1252
+ else:
1253
+ results[item_id] = global_baseline
1254
+
1255
+ return results
1256
+
1257
+
1258
+ def is_readable(visibility: VisibilityState) -> bool:
1259
+ """Check if an item with this visibility can be read.
1260
+
1261
+ Only visible items can have their content read.
1262
+ Hidden and opaque items are blocked at the visibility layer.
1263
+ """
1264
+ return visibility == "visible"