toolgraph 0.0.1__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.
@@ -0,0 +1,166 @@
1
+ """Graph schema: uniqueness constraints.
2
+
3
+ Single-property uniqueness only — these work on Neo4j Community (node-key and
4
+ composite-key constraints are Enterprise-only). Tools therefore carry a synthetic
5
+ ``key`` (``"<server>::<tool>"``) so identity is a single indexed property and
6
+ MERGE stays idempotent. No vector index in this MVP.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from uuid import uuid4
12
+
13
+ from toolgraph.graph.driver import BackendConfigurationError, backend_name, session
14
+
15
+ # Version of the physical backend schema this code understands. Bumped only
16
+ # when a table/constraint shape changes incompatibly. IF NOT EXISTS silently
17
+ # skips outdated tables, so without this gate an old database meets new code
18
+ # only as confusing query-time errors.
19
+ BACKEND_SCHEMA_VERSION = 1
20
+
21
+ CONSTRAINTS: list[str] = [
22
+ "CREATE CONSTRAINT mcpserver_name IF NOT EXISTS FOR (s:MCPServer) REQUIRE s.name IS UNIQUE",
23
+ "CREATE CONSTRAINT tool_key IF NOT EXISTS FOR (t:Tool) REQUIRE t.key IS UNIQUE",
24
+ "CREATE CONSTRAINT resource_uri IF NOT EXISTS FOR (r:Resource) REQUIRE r.uri IS UNIQUE",
25
+ "CREATE CONSTRAINT agent_id IF NOT EXISTS FOR (a:Agent) REQUIRE a.id IS UNIQUE",
26
+ "CREATE CONSTRAINT policy_id IF NOT EXISTS FOR (p:Policy) REQUIRE p.id IS UNIQUE",
27
+ "CREATE CONSTRAINT exception_key IF NOT EXISTS FOR (e:ExpectedException) REQUIRE e.key IS UNIQUE",
28
+ "CREATE CONSTRAINT graphmeta_id IF NOT EXISTS FOR (m:GraphMeta) REQUIRE m.id IS UNIQUE",
29
+ ]
30
+
31
+ # ADR-0004: run in the SAME transaction as every successful crawl load and
32
+ # manifest ingest, so generation and graph state can never be observed out of
33
+ # sync. It is an invalidation token, not a semantic version — a crawl of N
34
+ # servers bumps N times. Rejected ingests return before reaching this.
35
+ BUMP_GENERATION = (
36
+ "MERGE (m:GraphMeta {id:'singleton'}) "
37
+ "SET m.instance_id = coalesce(m.instance_id, $graph_instance_id), "
38
+ "m.generation = coalesce(m.generation, 0) + 1, m.updated_at = datetime()"
39
+ )
40
+
41
+ ENSURE_GRAPH_META = (
42
+ "MERGE (m:GraphMeta {id:'singleton'}) "
43
+ "SET m.instance_id = coalesce(m.instance_id, $graph_instance_id), "
44
+ "m.generation = coalesce(m.generation, 0)"
45
+ )
46
+
47
+ LADYBUG_SCHEMA: list[str] = [
48
+ "CREATE NODE TABLE IF NOT EXISTS MCPServer("
49
+ "name STRING PRIMARY KEY, version STRING, transport STRING, endpoint STRING, "
50
+ "last_crawled_at TIMESTAMP)",
51
+ "CREATE NODE TABLE IF NOT EXISTS Tool("
52
+ "key STRING PRIMARY KEY, name STRING, server STRING, description STRING, "
53
+ "input_schema STRING, read_only_hint BOOL, destructive_hint BOOL, "
54
+ "idempotent_hint BOOL, open_world_hint BOOL)",
55
+ "CREATE NODE TABLE IF NOT EXISTS Resource("
56
+ "uri STRING PRIMARY KEY, name STRING, mime_type STRING, description STRING)",
57
+ "CREATE NODE TABLE IF NOT EXISTS Agent(id STRING PRIMARY KEY)",
58
+ "CREATE NODE TABLE IF NOT EXISTS Policy("
59
+ "id STRING PRIMARY KEY, effect STRING, scope STRING, description STRING, "
60
+ "prov_source STRING, prov_confidence STRING, prov_evidence STRING)",
61
+ "CREATE NODE TABLE IF NOT EXISTS ExpectedException("
62
+ "key STRING PRIMARY KEY, agent STRING, tool_key STRING, resource STRING, "
63
+ "policy STRING, reason STRING)",
64
+ "CREATE NODE TABLE IF NOT EXISTS GraphMeta("
65
+ "id STRING PRIMARY KEY, instance_id STRING, generation INT64, updated_at TIMESTAMP, "
66
+ "governance_digest STRING, last_policy_change_id STRING, backend_schema_version INT64)",
67
+ "CREATE REL TABLE IF NOT EXISTS EXPOSES("
68
+ "FROM MCPServer TO Tool, source STRING, confidence STRING, evidence STRING)",
69
+ "CREATE REL TABLE IF NOT EXISTS PROVIDES("
70
+ "FROM MCPServer TO Resource, source STRING, confidence STRING, evidence STRING)",
71
+ "CREATE REL TABLE IF NOT EXISTS CAN_CALL("
72
+ "FROM Agent TO Tool, granted_by STRING, source STRING, confidence STRING, evidence STRING)",
73
+ "CREATE REL TABLE IF NOT EXISTS READS("
74
+ "FROM Tool TO Resource, source STRING, confidence STRING, evidence STRING)",
75
+ "CREATE REL TABLE IF NOT EXISTS WRITES("
76
+ "FROM Tool TO Resource, source STRING, confidence STRING, evidence STRING)",
77
+ "CREATE REL TABLE IF NOT EXISTS GOVERNED_BY("
78
+ "FROM Resource TO Policy, FROM Tool TO Policy, "
79
+ "source STRING, confidence STRING, evidence STRING)",
80
+ ]
81
+
82
+
83
+ def exception_key(agent: str, tool_key: str, resource: str | None, policy: str) -> str:
84
+ """Collision-safe identity; null denotes an exception for any resource."""
85
+ from toolgraph.artifacts import canonical_json_bytes, sha256_bytes
86
+ from toolgraph.uris import normalize_resource_uri
87
+
88
+ resource = normalize_resource_uri(resource) if resource is not None else None
89
+ return "v2:" + sha256_bytes(canonical_json_bytes([agent, tool_key, resource, policy]))
90
+
91
+
92
+ def tool_key(server_name: str, tool_name: str) -> str:
93
+ """Stable identity for a Tool node.
94
+
95
+ Components must not contain the delimiter: (server="a", tool="b::c") and
96
+ (server="a::b", tool="c") would otherwise collapse onto one key and MERGE
97
+ the same node. Model validation enforces this at the crawl boundary; this
98
+ check covers programmatic callers.
99
+ """
100
+ if "::" in server_name or "::" in tool_name:
101
+ raise ValueError(
102
+ "server and tool names must not contain '::' (the tool-key delimiter)"
103
+ )
104
+ return f"{server_name}::{tool_name}"
105
+
106
+
107
+ def _stored_backend_schema_version(s) -> int | None:
108
+ try:
109
+ record = s.run(
110
+ "MATCH (m:GraphMeta {id:'singleton'}) "
111
+ "RETURN m.backend_schema_version AS version"
112
+ ).single()
113
+ except RuntimeError:
114
+ # Fresh embedded database: the GraphMeta table does not exist yet.
115
+ return None
116
+ return record["version"] if record else None
117
+
118
+
119
+ def _refuse_version_mismatch(stored: int | None) -> None:
120
+ if stored is not None and stored != BACKEND_SCHEMA_VERSION:
121
+ raise BackendConfigurationError(
122
+ f"backend schema version {stored} does not match this build's "
123
+ f"{BACKEND_SCHEMA_VERSION} — "
124
+ + (
125
+ "upgrade toolgraph to a version that understands it"
126
+ if stored > BACKEND_SCHEMA_VERSION
127
+ else "migrate the database (no automatic migration exists)"
128
+ )
129
+ )
130
+
131
+
132
+ def init_schema() -> None:
133
+ """Create all constraints (idempotent) and gate on the schema version."""
134
+ with session() as s:
135
+ # Gate BEFORE running any version-specific DDL: an incompatible client
136
+ # must not mutate the database on its way to the mismatch error.
137
+ _refuse_version_mismatch(_stored_backend_schema_version(s))
138
+ for stmt in LADYBUG_SCHEMA if backend_name() == "ladybug" else CONSTRAINTS:
139
+ s.run(stmt)
140
+ # Backfill databases created before the graph-state contract without
141
+ # invalidating caches: no authored or crawled graph fact changed.
142
+ s.run(ENSURE_GRAPH_META, graph_instance_id=str(uuid4()))
143
+ # Stamp without clobbering: coalesce keeps whatever version a
144
+ # concurrent initializer already wrote; every shipped database has the
145
+ # v1 shape, so a null stamp is always safe to fill.
146
+ s.run(
147
+ "MATCH (m:GraphMeta {id:'singleton'}) "
148
+ "SET m.backend_schema_version = "
149
+ "coalesce(m.backend_schema_version, $version)",
150
+ version=BACKEND_SCHEMA_VERSION,
151
+ )
152
+ # Re-read: if a different-version initializer won the coalesce race,
153
+ # refuse now instead of proceeding against its database.
154
+ _refuse_version_mismatch(_stored_backend_schema_version(s))
155
+
156
+
157
+ def list_constraints() -> list[dict]:
158
+ if backend_name() == "ladybug":
159
+ return [{"name": statement.split("EXISTS", 1)[1].split("(", 1)[0].strip()}
160
+ for statement in LADYBUG_SCHEMA]
161
+ with session() as s:
162
+ return [dict(r) for r in s.run("SHOW CONSTRAINTS")]
163
+
164
+
165
+ def bump_generation(tx) -> None:
166
+ tx.run(BUMP_GENERATION, graph_instance_id=str(uuid4()))
@@ -0,0 +1,423 @@
1
+ """Deterministic selector surface (ADR-0005): filter/features in, learning out.
2
+
3
+ Three batch-first entry points serve tool selection as a context-engineering
4
+ layer — shrink a candidate catalog deterministically before any relevance
5
+ ranker sees it:
6
+
7
+ - ``rank_features`` — per-candidate facts + a rule-based ``risk_score`` from
8
+ the published fixed table below. No relevance, no learning.
9
+ - ``eligible_tools`` — hard filter with reject reasons and policy-evidence
10
+ paths. Profiles are named rule sets, not learned thresholds; production
11
+ default is ``strict``.
12
+ - ``selection_explain`` — compact human-readable reasons for one (agent, tool).
13
+
14
+ Batch-first because per-candidate ``check_access`` calls scale linearly with
15
+ candidate count — unacceptable on the online selection path. Everything here
16
+ runs a fixed number of queries regardless of N.
17
+
18
+ Hard boundary (ADR-0005): no relevance scoring, no learning. A learned
19
+ consumer may never override a hard reject (NOT_GRANTED, violation, drifted).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from toolgraph.graph.driver import session
25
+ from toolgraph.graph.queries import _deny_evidence_batch, agent_exists, resolve_tool_refs
26
+
27
+ # Hard-filter rule sets (ADR-0005). Reasons are checked in _REASON_PRECEDENCE
28
+ # order; the first applicable reason that the profile rejects wins. These are
29
+ # named rule sets, NOT tunable thresholds — changing them is an ADR-level
30
+ # decision, not a config knob.
31
+ #
32
+ # - strict (production default): only a clean, fully-authored ALLOW passes —
33
+ # granted, no DENY path of any classification, currently exposed, and with
34
+ # authored data-flow (an unmapped tool is exactly the blind spot the graph
35
+ # cannot vouch for, so production fails closed).
36
+ # - review (human-in-the-loop): operator-declared exceptions and unmapped
37
+ # tools pass; violations still reject.
38
+ # - explore (offline eval / sandbox): only resolution failures, missing
39
+ # grants, and drift reject — DENY rows become penalties via rank_features.
40
+ PROFILES: dict[str, frozenset[str]] = {
41
+ "strict": frozenset(
42
+ {
43
+ "TOOL_NOT_FOUND",
44
+ "AMBIGUOUS_TOOL",
45
+ "NOT_GRANTED",
46
+ "DENY_VIOLATION",
47
+ "DENY_GOVERNED",
48
+ "DRIFTED",
49
+ "UNMAPPED",
50
+ }
51
+ ),
52
+ "review": frozenset(
53
+ {"TOOL_NOT_FOUND", "AMBIGUOUS_TOOL", "NOT_GRANTED", "DENY_VIOLATION", "DRIFTED"}
54
+ ),
55
+ "explore": frozenset({"TOOL_NOT_FOUND", "AMBIGUOUS_TOOL", "NOT_GRANTED", "DRIFTED"}),
56
+ }
57
+
58
+ DEFAULT_PROFILE = "strict"
59
+
60
+ # Batch bound, mirroring the control-plan limit: the selector is batch-first,
61
+ # but an unbounded candidate list still UNWINDs as one query parameter. Lives
62
+ # here so every surface — CLI, MCP server, library callers — is bounded.
63
+ MAX_CANDIDATES = 4096
64
+
65
+ # Drift rejects in EVERY profile (the report's regression criteria): a tool no
66
+ # server currently exposes cannot be called, so passing it is never useful.
67
+ _REASON_PRECEDENCE = (
68
+ "TOOL_NOT_FOUND",
69
+ "AMBIGUOUS_TOOL",
70
+ "NOT_GRANTED",
71
+ "DENY_VIOLATION",
72
+ "DENY_GOVERNED",
73
+ "DRIFTED",
74
+ "UNMAPPED",
75
+ )
76
+
77
+
78
+ def _risk_score(feature: dict) -> float | None:
79
+ """Rule-based risk from the published fixed table (ADR-0005 / report).
80
+
81
+ First match wins, top-down. This is a fact-derived feature, not a learned
82
+ weight — consumers may add penalties on top but never subtract a hard
83
+ reject. ``None`` when the candidate didn't resolve to exactly one tool
84
+ (no facts to score).
85
+
86
+ 1.0 DENY path with classification=violation
87
+ 0.8 drifted (no live EXPOSES edge)
88
+ 0.6 unmapped (no authored READS/WRITES)
89
+ 0.4 unbacked load-bearing edge in the tool's evidence chain
90
+ 0.2 DENY path fully covered by operator-declared exceptions
91
+ 0.0 otherwise
92
+ """
93
+ if not feature["found"] or feature["ambiguous"]:
94
+ return None
95
+ if feature["classification"] == "violation":
96
+ return 1.0
97
+ if feature["is_drifted"]:
98
+ return 0.8
99
+ if feature["is_unmapped"]:
100
+ return 0.6
101
+ if feature["has_unbacked_edges"]:
102
+ return 0.4
103
+ if feature["classification"] == "authorized_but_governed":
104
+ return 0.2
105
+ return 0.0
106
+
107
+
108
+ def _resolve_all(s, refs: list[str]) -> dict[str, list[str]]:
109
+ """Use the same bounded resolution as ingest and single-tool queries."""
110
+ return resolve_tool_refs(s, refs)
111
+
112
+
113
+ def _tool_facts(s, keys: list[str], agent: str) -> dict[str, dict]:
114
+ """Batch per-tool facts for resolved keys: grant, drift, mapping, evidence
115
+ coverage, and the #14 annotation self-claims (which enrich features but
116
+ never gate by themselves — they are crawled/medium claims, ADR-0006)."""
117
+ rows = s.run(
118
+ """
119
+ MATCH (t:Tool) WHERE t.key IN $keys
120
+ RETURN t.key AS key,
121
+ t.name AS name, t.server AS server,
122
+ t.read_only_hint AS read_only_hint,
123
+ t.destructive_hint AS destructive_hint,
124
+ t.idempotent_hint AS idempotent_hint,
125
+ t.open_world_hint AS open_world_hint,
126
+ EXISTS { MATCH (:Agent {id:$agent})-[:CAN_CALL]->(t) } AS permitted,
127
+ NOT EXISTS { MATCH (:MCPServer)-[:EXPOSES]->(t) } AS is_drifted,
128
+ NOT EXISTS { MATCH (t)-[:READS|WRITES]->(:Resource) } AS is_unmapped,
129
+ EXISTS {
130
+ MATCH (t)-[r:READS|WRITES|GOVERNED_BY]->()
131
+ WHERE r.evidence IS NULL OR trim(r.evidence) = ''
132
+ } OR EXISTS {
133
+ MATCH (t)-[:READS|WRITES]->(:Resource)-[g:GOVERNED_BY]->()
134
+ WHERE g.evidence IS NULL OR trim(g.evidence) = ''
135
+ } AS has_unbacked_edges
136
+ """,
137
+ keys=keys,
138
+ agent=agent,
139
+ )
140
+ return {r["key"]: dict(r) for r in rows}
141
+
142
+
143
+ def _classify(deny_rows: list[dict]) -> str | None:
144
+ """Worst-case classification across a tool's deny paths: any violation row
145
+ dominates; all-exception rows collapse to authorized_but_governed."""
146
+ if not deny_rows:
147
+ return None
148
+ if any(d.get("classification") == "violation" for d in deny_rows):
149
+ return "violation"
150
+ return "authorized_but_governed"
151
+
152
+
153
+ def rank_features(agent: str, candidates: list[str]) -> dict:
154
+ """Batch selection features for every candidate, in input order.
155
+
156
+ ``agent_found: false`` means selection itself should abort (a context
157
+ construction error, not an empty result) — features come back empty.
158
+
159
+ Each feature row keeps the input ref as its stable id (``candidate``) and
160
+ carries: resolution (found/ambiguous/tool_key), grant (``permitted``),
161
+ verdict (mirrors ``check_access``), worst-case deny ``classification``
162
+ with the full ``deny_paths``, drift / mapping / evidence-coverage facts,
163
+ the four annotation self-claims (ADR-0006), and the rule-based
164
+ ``risk_score`` (see ``_risk_score`` for the fixed table).
165
+ """
166
+ if len(candidates) > MAX_CANDIDATES:
167
+ raise ValueError(
168
+ f"{len(candidates)} candidates exceed the {MAX_CANDIDATES} limit"
169
+ )
170
+ if not agent_exists(agent):
171
+ return {"agent": agent, "agent_found": False, "features": []}
172
+
173
+ with session() as s:
174
+ resolved = _resolve_all(s, candidates)
175
+ unique_keys = sorted(
176
+ {keys[0] for keys in resolved.values() if len(keys) == 1}
177
+ )
178
+ facts = _tool_facts(s, unique_keys, agent)
179
+ deny = _deny_evidence_batch(s, unique_keys, agent=agent)
180
+
181
+ features: list[dict] = []
182
+ for ref in candidates: # input order is the contract — never re-sorted
183
+ keys = resolved.get(ref, [])
184
+ row: dict = {
185
+ "candidate": ref,
186
+ "tool_key": None,
187
+ "found": bool(keys),
188
+ "ambiguous": len(keys) > 1,
189
+ "permitted": None,
190
+ "verdict": None,
191
+ "classification": None,
192
+ "deny_paths": [],
193
+ "is_drifted": None,
194
+ "is_unmapped": None,
195
+ "has_unbacked_edges": None,
196
+ "read_only_hint": None,
197
+ "destructive_hint": None,
198
+ "idempotent_hint": None,
199
+ "open_world_hint": None,
200
+ }
201
+ if not keys:
202
+ row["verdict"] = "TOOL_NOT_FOUND"
203
+ elif len(keys) > 1:
204
+ # Never auto-resolved: the consumer must re-submit the
205
+ # server-qualified key (regression criterion #1).
206
+ row["verdict"] = "AMBIGUOUS_TOOL"
207
+ row["candidates"] = keys
208
+ elif keys[0] not in facts:
209
+ # Tool deleted between resolution and the facts read — Neo4j is
210
+ # read-committed, so the two batch queries can straddle a
211
+ # concurrent crawl/ingest (the same window check_access closes
212
+ # with its meta-is-None branch). Report the truth signal instead
213
+ # of crashing the whole batch (Codex review).
214
+ row["found"] = False
215
+ row["verdict"] = "TOOL_NOT_FOUND"
216
+ else:
217
+ key = keys[0]
218
+ f = facts[key]
219
+ deny_rows = deny[key]
220
+ row["tool_key"] = key
221
+ row["permitted"] = f["permitted"]
222
+ row["classification"] = _classify(deny_rows)
223
+ row["deny_paths"] = deny_rows
224
+ row["is_drifted"] = f["is_drifted"]
225
+ row["is_unmapped"] = f["is_unmapped"]
226
+ row["has_unbacked_edges"] = f["has_unbacked_edges"]
227
+ for hint in (
228
+ "read_only_hint",
229
+ "destructive_hint",
230
+ "idempotent_hint",
231
+ "open_world_hint",
232
+ ):
233
+ row[hint] = f[hint]
234
+ if not f["permitted"]:
235
+ row["verdict"] = "NOT_GRANTED"
236
+ elif deny_rows:
237
+ row["verdict"] = "DENY"
238
+ else:
239
+ row["verdict"] = "ALLOW"
240
+ row["risk_score"] = _risk_score(row)
241
+ features.append(row)
242
+
243
+ return {"agent": agent, "agent_found": True, "features": features}
244
+
245
+
246
+ def _applicable_reasons(feature: dict) -> list[str]:
247
+ """All reject conditions this feature row triggers, in precedence order."""
248
+ reasons: list[str] = []
249
+ if not feature["found"]:
250
+ reasons.append("TOOL_NOT_FOUND")
251
+ return reasons # nothing else is knowable about an unresolved ref
252
+ if feature["ambiguous"]:
253
+ reasons.append("AMBIGUOUS_TOOL")
254
+ return reasons
255
+ if not feature["permitted"]:
256
+ reasons.append("NOT_GRANTED")
257
+ if feature["classification"] == "violation":
258
+ reasons.append("DENY_VIOLATION")
259
+ elif feature["classification"] == "authorized_but_governed":
260
+ reasons.append("DENY_GOVERNED")
261
+ if feature["is_drifted"]:
262
+ reasons.append("DRIFTED")
263
+ if feature["is_unmapped"]:
264
+ reasons.append("UNMAPPED")
265
+ return reasons
266
+
267
+
268
+ def filter_features(ranked: dict, profile: str = DEFAULT_PROFILE) -> dict:
269
+ """Pure hard filter over an already-computed ``rank_features`` result.
270
+
271
+ Runs no queries: producers that need both the filter verdict and the
272
+ feature rows (policy bundle, preflight ``--features``, explain) call
273
+ ``rank_features`` once and filter its output, instead of paying the full
274
+ graph evaluation twice inside the generation-retry bracket.
275
+ """
276
+ if profile not in PROFILES:
277
+ raise ValueError(
278
+ f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
279
+ )
280
+ agent = ranked["agent"]
281
+ if not ranked["agent_found"]:
282
+ return {
283
+ "agent": agent,
284
+ "agent_found": False,
285
+ "profile": profile,
286
+ "eligible": [],
287
+ "rejected": [],
288
+ }
289
+
290
+ rejects = PROFILES[profile]
291
+ eligible: list[str] = []
292
+ rejected: list[dict] = []
293
+ for feature in ranked["features"]:
294
+ reason = next(
295
+ (r for r in _applicable_reasons(feature) if r in rejects), None
296
+ )
297
+ if reason is None:
298
+ eligible.append(feature["tool_key"])
299
+ continue
300
+ row = {
301
+ "candidate": feature["candidate"],
302
+ "tool_key": feature["tool_key"],
303
+ "reason": reason,
304
+ }
305
+ if reason == "AMBIGUOUS_TOOL":
306
+ row["candidates"] = feature["candidates"]
307
+ if reason in ("DENY_VIOLATION", "DENY_GOVERNED"):
308
+ wanted = (
309
+ "violation" if reason == "DENY_VIOLATION" else "authorized_but_governed"
310
+ )
311
+ row["paths"] = [
312
+ d["path"]
313
+ for d in feature["deny_paths"]
314
+ if d.get("classification") == wanted
315
+ ]
316
+ rejected.append(row)
317
+
318
+ return {
319
+ "agent": agent,
320
+ "agent_found": True,
321
+ "profile": profile,
322
+ "eligible": eligible,
323
+ "rejected": rejected,
324
+ }
325
+
326
+
327
+ def eligible_tools(
328
+ agent: str, candidates: list[str], profile: str = DEFAULT_PROFILE
329
+ ) -> dict:
330
+ """Hard filter with reject reasons and policy-evidence paths (ADR-0005).
331
+
332
+ ``eligible`` holds resolved tool keys in candidate input order; every
333
+ rejected row keeps the input ref (``candidate``), the winning ``reason``
334
+ (first by precedence among the profile's reject set), and — for DENY
335
+ reasons — the graph paths that prove it. A learned consumer may rerank
336
+ ``eligible`` but may never resurrect a rejected row.
337
+ """
338
+ if profile not in PROFILES:
339
+ raise ValueError(
340
+ f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
341
+ )
342
+ return filter_features(rank_features(agent, candidates), profile)
343
+
344
+
345
+ def selection_explain(
346
+ agent: str, tool: str, profile: str = DEFAULT_PROFILE
347
+ ) -> dict:
348
+ """Compact human-readable reasons for one (agent, tool) — a view over the
349
+ same facts ``rank_features`` returns, for operators and end users."""
350
+ if profile not in PROFILES:
351
+ raise ValueError(
352
+ f"unknown profile {profile!r} — expected one of {sorted(PROFILES)}"
353
+ )
354
+ ranked = rank_features(agent, [tool])
355
+ if not ranked["agent_found"]:
356
+ return {
357
+ "agent": agent,
358
+ "tool": tool,
359
+ "tool_key": None,
360
+ "agent_found": False,
361
+ "profile": profile,
362
+ "decision": "rejected",
363
+ "reasons": [f"agent {agent!r} not found in the graph — selection aborted"],
364
+ }
365
+
366
+ feature = ranked["features"][0]
367
+ filtered = filter_features(ranked, profile)
368
+ decision = "eligible" if filtered["eligible"] else "rejected"
369
+
370
+ reasons: list[str] = []
371
+ if not feature["found"]:
372
+ reasons.append(f"no tool named {tool!r} in the graph")
373
+ elif feature["ambiguous"]:
374
+ reasons.append(
375
+ f"bare name matches multiple servers {feature['candidates']} — "
376
+ f"pass the server-qualified key; never auto-selected"
377
+ )
378
+ else:
379
+ reasons.append(
380
+ "agent has CAN_CALL grant"
381
+ if feature["permitted"]
382
+ else "agent has no CAN_CALL grant"
383
+ )
384
+ if not feature["deny_paths"]:
385
+ reasons.append("no DENY policy path found")
386
+ for d in feature["deny_paths"]:
387
+ label = d.get("classification", "deny")
388
+ line = f"DENY path [{label}]: {d['path']}"
389
+ if d.get("exception_reason"):
390
+ line += f" (operator: {d['exception_reason']})"
391
+ reasons.append(line)
392
+ reasons.append(
393
+ "tool has drifted: no server currently exposes it"
394
+ if feature["is_drifted"]
395
+ else "tool is currently exposed by a crawled server"
396
+ )
397
+ reasons.append(
398
+ "no authored READS/WRITES — data-flow effects unclassified"
399
+ if feature["is_unmapped"]
400
+ else "data-flow effects are authored (READS/WRITES present)"
401
+ )
402
+ reasons.append(
403
+ "load-bearing edge(s) missing evidence pointers"
404
+ if feature["has_unbacked_edges"]
405
+ else "all load-bearing edges carry evidence"
406
+ )
407
+ for hint in ("read_only_hint", "destructive_hint", "idempotent_hint", "open_world_hint"):
408
+ if feature[hint] is not None:
409
+ reasons.append(
410
+ f"server self-claim (crawled/medium): {hint}={feature[hint]}"
411
+ )
412
+
413
+ return {
414
+ "agent": agent,
415
+ "tool": tool,
416
+ "tool_key": feature["tool_key"],
417
+ "agent_found": True,
418
+ "found": feature["found"],
419
+ "profile": profile,
420
+ "decision": decision,
421
+ "risk_score": feature["risk_score"],
422
+ "reasons": reasons,
423
+ }
@@ -0,0 +1,57 @@
1
+ """Domain-level graph ports used by policy services.
2
+
3
+ No Cypher or backend result object crosses this boundary. The current runtime
4
+ implementation delegates to the established query/selector services; backend
5
+ adapters remain responsible for DDL, transactions, and row normalization.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Protocol
11
+
12
+ from toolgraph.graph import queries, selector
13
+
14
+
15
+ class GraphReader(Protocol):
16
+ def state(self) -> queries.GraphState: ...
17
+
18
+ def exposed_tool_contracts(self) -> list[dict]: ...
19
+
20
+ def eligible_tools(self, agent: str, candidates: list[str], profile: str) -> dict: ...
21
+
22
+ def rank_features(self, agent: str, candidates: list[str]) -> dict: ...
23
+
24
+
25
+ class EvaluatingGraphReader(GraphReader, Protocol):
26
+ # Optional: adapters that can evaluate once and filter in memory expose
27
+ # this combined form; build_policy_bundle prefers it and falls back to the
28
+ # eligible_tools + rank_features pair for adapters that do not.
29
+ def evaluate(self, agent: str, candidates: list[str], profile: str) -> dict: ...
30
+
31
+
32
+ class RuntimeGraphStore:
33
+ """Backend-neutral policy read service over the configured graph adapter."""
34
+
35
+ def state(self) -> queries.GraphState:
36
+ return queries.graph_state()
37
+
38
+ def exposed_tool_contracts(self) -> list[dict]:
39
+ return queries.exposed_tool_contracts()
40
+
41
+ def eligible_tools(self, agent: str, candidates: list[str], profile: str) -> dict:
42
+ return selector.eligible_tools(agent, candidates, profile=profile)
43
+
44
+ def rank_features(self, agent: str, candidates: list[str]) -> dict:
45
+ return selector.rank_features(agent, candidates)
46
+
47
+ def evaluate(self, agent: str, candidates: list[str], profile: str) -> dict:
48
+ """One graph evaluation; the hard filter is pure over the ranked rows."""
49
+ ranked = selector.rank_features(agent, candidates)
50
+ if not ranked["agent_found"]:
51
+ return {"agent_found": False, "features": [], "filtered": None}
52
+ filtered = selector.filter_features(ranked, profile)
53
+ return {
54
+ "agent_found": True,
55
+ "features": ranked["features"],
56
+ "filtered": filtered,
57
+ }
File without changes