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.
toolgraph/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Runtime settings, sourced from the environment (and an optional .env file)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from dotenv import load_dotenv
11
+
12
+ load_dotenv(dotenv_path=Path.cwd() / ".env", override=False)
13
+
14
+ _explicit_config_path: Path | None = None
15
+
16
+
17
+ def runtime_config_path() -> Path:
18
+ value = _explicit_config_path or os.getenv("TOOLGRAPH_CONFIG", ".toolgraph/config.json")
19
+ return Path(value).expanduser()
20
+
21
+
22
+ class RuntimeConfigurationError(RuntimeError):
23
+ """The selected runtime configuration cannot be loaded."""
24
+
25
+
26
+ _MAX_CONFIG_BYTES = 1_000_000
27
+
28
+
29
+ def _runtime_config() -> dict:
30
+ path = runtime_config_path()
31
+ if not path.exists():
32
+ return {}
33
+ try:
34
+ # Bounded read from one open descriptor (stat-then-read is a TOCTOU).
35
+ with path.open("rb") as handle:
36
+ raw = handle.read(_MAX_CONFIG_BYTES + 1)
37
+ if len(raw) > _MAX_CONFIG_BYTES:
38
+ raise RuntimeConfigurationError(
39
+ f"invalid Toolgraph runtime config at {path}: exceeds"
40
+ f" the {_MAX_CONFIG_BYTES}-byte limit"
41
+ )
42
+ value = json.loads(raw.decode("utf-8"))
43
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
44
+ raise RuntimeConfigurationError(f"invalid Toolgraph runtime config at {path}: {exc}") from exc
45
+ if not isinstance(value, dict) or value.get("schema_version") != 1:
46
+ raise RuntimeConfigurationError(f"unsupported Toolgraph runtime config at {path}")
47
+ return value
48
+
49
+
50
+ def _backend_default() -> str:
51
+ runtime = _runtime_config()
52
+ if _explicit_config_path is not None:
53
+ return str(runtime.get("backend", "neo4j"))
54
+ return os.getenv("TOOLGRAPH_BACKEND") or str(runtime.get("backend", "neo4j"))
55
+
56
+
57
+ def _db_path_default() -> Path:
58
+ runtime = _runtime_config()
59
+ if _explicit_config_path is not None:
60
+ value = runtime.get("db_path", ".toolgraph/toolgraph.lbug")
61
+ else:
62
+ value = os.getenv("TOOLGRAPH_DB_PATH") or runtime.get(
63
+ "db_path", ".toolgraph/toolgraph.lbug"
64
+ )
65
+ return Path(str(value)).expanduser()
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Settings:
70
+ # default_factory so each Settings() re-reads the environment (tests build
71
+ # their own Settings; the module-level singleton reads it once on first access).
72
+ neo4j_uri: str = field(default_factory=lambda: os.getenv("NEO4J_URI", "bolt://localhost:7687"))
73
+ neo4j_user: str = field(default_factory=lambda: os.getenv("NEO4J_USER", "neo4j"))
74
+ neo4j_password: str = field(default_factory=lambda: os.getenv("NEO4J_PASSWORD", "toolgraph-dev"))
75
+ neo4j_database: str = field(default_factory=lambda: os.getenv("NEO4J_DATABASE", "neo4j"))
76
+ backend: str = field(default_factory=_backend_default)
77
+ db_path: Path = field(default_factory=_db_path_default)
78
+ servers_config: Path = field(
79
+ default_factory=lambda: Path(os.getenv("TOOLGRAPH_SERVERS", "servers.yaml"))
80
+ )
81
+ governance_config: Path = field(
82
+ default_factory=lambda: Path(os.getenv("TOOLGRAPH_GOVERNANCE", "governance.yaml"))
83
+ )
84
+
85
+
86
+ def __getattr__(name: str):
87
+ if name == "settings":
88
+ value = Settings()
89
+ globals()[name] = value
90
+ return value
91
+ raise AttributeError(name)
92
+
93
+
94
+ def configure(config_path: Path) -> None:
95
+ """Select an explicit runtime config (CLI > env > cwd .env > default)."""
96
+ global _explicit_config_path, settings
97
+ selected = config_path.expanduser().resolve()
98
+ if not selected.is_file():
99
+ raise RuntimeConfigurationError(f"Toolgraph runtime config does not exist: {selected}")
100
+ previous = _explicit_config_path
101
+ _explicit_config_path = selected
102
+ try:
103
+ settings = Settings()
104
+ except Exception:
105
+ _explicit_config_path = previous
106
+ raise
@@ -0,0 +1,494 @@
1
+ """Body-free orchestration control-plan validation and advisory preflight.
2
+
3
+ The plan is an upper-bound control graph supplied by an external orchestrator.
4
+ Toolgraph never executes or persists it: it validates the bounded DAG and joins
5
+ every declared principal/tool surface to the existing governance graph under one
6
+ collision-safe graph-state bracket.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import deque
12
+ from datetime import datetime, timezone
13
+ import json
14
+ import re
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
18
+
19
+ from toolgraph.artifacts import rfc3339_offset_error, sha256_bytes
20
+ from toolgraph.graph import queries, selector
21
+ from toolgraph.artifact_safety import _CREDENTIAL_QUERY, _CREDENTIAL_URI, safe_artifact
22
+
23
+ SCHEMA_VERSION = 1
24
+ PLAN_KIND = "toolgraph.control-plan"
25
+ RESULT_KIND = "toolgraph.control-preflight"
26
+ MAX_PLAN_BYTES = 1_000_000
27
+ # JSON nesting bound for the body-free walk (and json.loads' own recursion).
28
+ _MAX_PLAN_DEPTH = 100
29
+
30
+ _QUALIFIED_TOOL = re.compile(r"^[^:\s]+::[^:\s]+$")
31
+ _FORBIDDEN_KEYS = frozenset(
32
+ {
33
+ "api_key",
34
+ "body",
35
+ "command",
36
+ "commands",
37
+ "credential",
38
+ "credentials",
39
+ "env",
40
+ "gate_commands",
41
+ "headers",
42
+ "memory",
43
+ "model_selector",
44
+ "output",
45
+ "outputs",
46
+ "password",
47
+ "patch",
48
+ "prompt",
49
+ "prompts",
50
+ "repo_root",
51
+ "repository_path",
52
+ "secret",
53
+ "stderr",
54
+ "stdout",
55
+ "token",
56
+ "worktree",
57
+ }
58
+ )
59
+ _FORBIDDEN_KEY_FRAGMENTS = (
60
+ "api_key",
61
+ "command",
62
+ "credential",
63
+ "memory_body",
64
+ "model_selector",
65
+ "output",
66
+ "password",
67
+ "patch",
68
+ "prompt",
69
+ "secret",
70
+ "stderr",
71
+ "stdout",
72
+ "token",
73
+ )
74
+ _ABSOLUTE_PATH = re.compile(r"^(?:/|\\\\|[A-Za-z]:[\\/])")
75
+
76
+
77
+ class ControlPlanError(ValueError):
78
+ """The supplied plan cannot produce a trustworthy preflight artifact."""
79
+
80
+
81
+ class _AdditiveModel(BaseModel):
82
+ """Versioned artifact model: same-major additive fields are ignored."""
83
+
84
+ model_config = ConfigDict(extra="ignore", strict=True)
85
+
86
+ @model_validator(mode="before")
87
+ @classmethod
88
+ def _json_numbers(cls, value):
89
+ if not isinstance(value, dict):
90
+ return value
91
+ value = dict(value)
92
+ for name, field in cls.model_fields.items():
93
+ if name not in value:
94
+ continue
95
+ item = value[name]
96
+ if item is None and not field.is_required():
97
+ raise ValueError(f"{name} cannot be null")
98
+ # JSON Schema treats integral JSON numbers as integers, but never
99
+ # booleans. Pydantic Literal[0/1] alone accepts bool equality.
100
+ numeric = name in {
101
+ "schema_version", "maximum_cycles", "maximum_agent_calls",
102
+ "maximum_parallelism", "max_invocations", "max_parallelism",
103
+ "timeout_seconds", "max_fan_out",
104
+ }
105
+ if numeric:
106
+ if isinstance(item, bool):
107
+ raise ValueError(f"{name} must be an integer")
108
+ if isinstance(item, float) and item.is_integer():
109
+ value[name] = int(item)
110
+ return value
111
+
112
+
113
+ class Producer(_AdditiveModel):
114
+ name: str = Field(min_length=1, max_length=128, pattern=r"^\S+$")
115
+ version: str = Field(min_length=1, max_length=128, pattern=r"^\S+$")
116
+
117
+
118
+ class Principal(_AdditiveModel):
119
+ id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
120
+ candidate_tools: list[str] = Field(min_length=1, max_length=4096)
121
+
122
+ @model_validator(mode="after")
123
+ def _qualified_unique_tools(self) -> "Principal":
124
+ bad = [tool for tool in self.candidate_tools if not _QUALIFIED_TOOL.fullmatch(tool)]
125
+ if bad:
126
+ raise ValueError(
127
+ "candidate_tools must use qualified 'server::tool' keys: "
128
+ + ", ".join(repr(tool) for tool in bad)
129
+ )
130
+ if len(set(self.candidate_tools)) != len(self.candidate_tools):
131
+ raise ValueError("candidate_tools must be unique within one principal")
132
+ return self
133
+
134
+
135
+ NodeKind = Literal["start", "agent", "router", "fanout", "join", "validator", "end"]
136
+
137
+
138
+ class ControlNode(_AdditiveModel):
139
+ id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
140
+ kind: NodeKind
141
+ # Required, not defaulted: contracts/control-plan.schema.json lists both in
142
+ # node.required, and the tolerant side here is the one that stamps the
143
+ # artifact — a plan this model accepted but the vendored schema rejects
144
+ # would make producer and consumer disagree about what a valid v1 plan is.
145
+ principals: list[str] = Field(max_length=256)
146
+ agent_call: bool
147
+ max_invocations: int | None = Field(default=None, ge=1)
148
+ max_parallelism: int | None = Field(default=None, ge=1)
149
+ timeout_seconds: int | None = Field(default=None, ge=1)
150
+ validator_scope: Literal["workspace_result"] | None = None
151
+
152
+ @model_validator(mode="after")
153
+ def _coherent_execution_shape(self) -> "ControlNode":
154
+ if (self.kind == "agent") != self.agent_call:
155
+ raise ValueError(
156
+ f"node {self.id!r} kind='agent' and agent_call must agree"
157
+ )
158
+ if len(set(self.principals)) != len(self.principals):
159
+ raise ValueError(f"node {self.id!r} principals must be unique")
160
+ if self.agent_call:
161
+ if not self.principals:
162
+ raise ValueError(f"agent-call node {self.id!r} requires principals")
163
+ missing = [
164
+ name
165
+ for name, value in (
166
+ ("max_invocations", self.max_invocations),
167
+ ("max_parallelism", self.max_parallelism),
168
+ ("timeout_seconds", self.timeout_seconds),
169
+ )
170
+ if value is None
171
+ ]
172
+ if missing:
173
+ raise ValueError(
174
+ f"agent-call node {self.id!r} requires {', '.join(missing)}"
175
+ )
176
+ assert self.max_invocations is not None
177
+ assert self.max_parallelism is not None
178
+ if self.max_parallelism > self.max_invocations:
179
+ raise ValueError(
180
+ f"node {self.id!r} max_parallelism cannot exceed max_invocations"
181
+ )
182
+ elif any(
183
+ value is not None
184
+ for value in (self.max_invocations, self.max_parallelism, self.timeout_seconds)
185
+ ):
186
+ raise ValueError(
187
+ f"non-agent-call node {self.id!r} cannot declare execution bounds"
188
+ )
189
+ if self.kind == "validator" and self.validator_scope is None:
190
+ raise ValueError(f"validator node {self.id!r} requires validator_scope")
191
+ if self.kind != "validator" and self.validator_scope is not None:
192
+ raise ValueError(
193
+ f"non-validator node {self.id!r} cannot declare validator_scope"
194
+ )
195
+ return self
196
+
197
+
198
+ EdgeKind = Literal["always", "success", "failure", "fallback", "fanout", "join"]
199
+
200
+
201
+ class ControlEdge(_AdditiveModel):
202
+ source: str = Field(alias="from", min_length=1, max_length=256, pattern=r"^\S+$")
203
+ target: str = Field(alias="to", min_length=1, max_length=256, pattern=r"^\S+$")
204
+ kind: EdgeKind
205
+ decided_by: Literal["code", "model", "human"]
206
+ max_fan_out: int | None = Field(default=None, ge=1)
207
+
208
+ @model_validator(mode="after")
209
+ def _coherent_fanout_bound(self) -> "ControlEdge":
210
+ if self.kind != "fanout" and self.max_fan_out is not None:
211
+ raise ValueError("max_fan_out is valid only on fanout edges")
212
+ return self
213
+
214
+
215
+ class ControlLimits(_AdditiveModel):
216
+ maximum_agent_calls: int = Field(ge=1)
217
+ maximum_parallelism: int = Field(ge=1)
218
+ maximum_cycles: Literal[0]
219
+
220
+
221
+ class ControlPlan(_AdditiveModel):
222
+ schema_version: Literal[1]
223
+ kind: Literal["toolgraph.control-plan"]
224
+ run_id: str = Field(min_length=1, max_length=256, pattern=r"^\S+$")
225
+ producer: Producer
226
+ principals: list[Principal] = Field(min_length=1, max_length=256)
227
+ nodes: list[ControlNode] = Field(min_length=1, max_length=4096)
228
+ edges: list[ControlEdge] = Field(max_length=16384)
229
+ limits: ControlLimits
230
+
231
+ @model_validator(mode="after")
232
+ def _unique_identities(self) -> "ControlPlan":
233
+ principal_ids = [principal.id for principal in self.principals]
234
+ if len(set(principal_ids)) != len(principal_ids):
235
+ raise ValueError("principal ids must be unique")
236
+ node_ids = [node.id for node in self.nodes]
237
+ if len(set(node_ids)) != len(node_ids):
238
+ raise ValueError("node ids must be unique")
239
+ known = set(principal_ids)
240
+ missing = sorted(
241
+ {
242
+ principal
243
+ for node in self.nodes
244
+ for principal in node.principals
245
+ if principal not in known
246
+ }
247
+ )
248
+ if missing:
249
+ raise ValueError(
250
+ "nodes reference undeclared principals: " + ", ".join(missing)
251
+ )
252
+ return self
253
+
254
+
255
+ def _assert_body_free(value: object, *, path: tuple[str, ...] = ()) -> None:
256
+ # A 1MB plan can still nest thousands of levels deep; cap the walk so a
257
+ # pathological plan fails as a typed error instead of a RecursionError.
258
+ if len(path) > _MAX_PLAN_DEPTH:
259
+ raise ControlPlanError(
260
+ f"control plan nesting exceeds the {_MAX_PLAN_DEPTH}-level limit"
261
+ )
262
+ if isinstance(value, dict):
263
+ for key, child in value.items():
264
+ lowered = str(key).lower()
265
+ if lowered in _FORBIDDEN_KEYS or any(
266
+ fragment in lowered for fragment in _FORBIDDEN_KEY_FRAGMENTS
267
+ ):
268
+ where = ".".join((*path, str(key)))
269
+ raise ControlPlanError(f"control plan contains forbidden field {where!r}")
270
+ _assert_body_free(child, path=(*path, str(key)))
271
+ return
272
+ if isinstance(value, list):
273
+ for index, child in enumerate(value):
274
+ _assert_body_free(child, path=(*path, str(index)))
275
+ return
276
+ if isinstance(value, str):
277
+ if _CREDENTIAL_URI.search(value):
278
+ raise ControlPlanError("control plan contains a credential-bearing URI")
279
+ if _CREDENTIAL_QUERY.search(value):
280
+ raise ControlPlanError("control plan contains a credential-bearing query")
281
+ if _ABSOLUTE_PATH.match(value):
282
+ raise ControlPlanError("control plan contains an absolute filesystem path")
283
+
284
+
285
+ def _unique_object(pairs: list[tuple[str, object]]) -> dict:
286
+ value: dict = {}
287
+ for key, child in pairs:
288
+ if key in value:
289
+ raise ControlPlanError(f"control plan contains duplicate key {key!r}")
290
+ value[key] = child
291
+ return value
292
+
293
+
294
+ def load_control_plan(raw: bytes) -> ControlPlan:
295
+ """Parse one size-bounded, body-free, same-major additive plan."""
296
+ if len(raw) > MAX_PLAN_BYTES:
297
+ raise ControlPlanError(
298
+ f"control plan exceeds the {MAX_PLAN_BYTES}-byte input limit"
299
+ )
300
+ try:
301
+ decoded = json.loads(raw, object_pairs_hook=_unique_object)
302
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
303
+ raise ControlPlanError(f"control plan is not valid UTF-8 JSON: {exc}") from exc
304
+ except RecursionError as exc:
305
+ # json.loads itself recurses; a deeply nested plan must fail typed.
306
+ raise ControlPlanError(
307
+ f"control plan nesting exceeds the {_MAX_PLAN_DEPTH}-level limit"
308
+ ) from exc
309
+ if not isinstance(decoded, dict):
310
+ raise ControlPlanError("control plan must be a JSON object")
311
+ _assert_body_free(decoded)
312
+ try:
313
+ return ControlPlan.model_validate(decoded)
314
+ except ValueError as exc:
315
+ raise ControlPlanError(f"invalid control plan: {exc}") from exc
316
+
317
+
318
+ def _finding(code: str, *, nodes: list[str] | None = None, edges: list[int] | None = None) -> dict:
319
+ row: dict = {"code": code}
320
+ if nodes:
321
+ row["nodes"] = sorted(set(nodes))
322
+ if edges:
323
+ row["edges"] = sorted(set(edges))
324
+ return row
325
+
326
+
327
+ def lint_control_plan(plan: ControlPlan) -> list[dict]:
328
+ """Return stable structural findings for the bounded v1 DAG contract."""
329
+ findings: list[dict] = []
330
+ nodes = {node.id: node for node in plan.nodes}
331
+ starts = sorted(node.id for node in plan.nodes if node.kind == "start")
332
+ ends = sorted(node.id for node in plan.nodes if node.kind == "end")
333
+ if len(starts) != 1:
334
+ findings.append(_finding("START_NODE_COUNT", nodes=starts))
335
+ if len(ends) != 1:
336
+ findings.append(_finding("END_NODE_COUNT", nodes=ends))
337
+
338
+ unknown_edges: list[int] = []
339
+ adjacency: dict[str, list[str]] = {node_id: [] for node_id in nodes}
340
+ reverse: dict[str, list[str]] = {node_id: [] for node_id in nodes}
341
+ indegree = {node_id: 0 for node_id in nodes}
342
+ for index, edge in enumerate(plan.edges):
343
+ if edge.source not in nodes or edge.target not in nodes:
344
+ unknown_edges.append(index)
345
+ continue
346
+ adjacency[edge.source].append(edge.target)
347
+ reverse[edge.target].append(edge.source)
348
+ indegree[edge.target] += 1
349
+ if unknown_edges:
350
+ findings.append(_finding("UNKNOWN_NODE_REFERENCE", edges=unknown_edges))
351
+
352
+ if len(starts) == 1:
353
+ reachable: set[str] = set()
354
+ queue = deque(starts)
355
+ while queue:
356
+ current = queue.popleft()
357
+ if current in reachable:
358
+ continue
359
+ reachable.add(current)
360
+ queue.extend(adjacency[current])
361
+ missing = sorted(set(nodes) - reachable)
362
+ if missing:
363
+ findings.append(_finding("UNREACHABLE_NODE", nodes=missing))
364
+
365
+ if len(ends) == 1:
366
+ terminal: set[str] = set()
367
+ queue = deque(ends)
368
+ while queue:
369
+ current = queue.popleft()
370
+ if current in terminal:
371
+ continue
372
+ terminal.add(current)
373
+ queue.extend(reverse[current])
374
+ missing = sorted(set(nodes) - terminal)
375
+ if missing:
376
+ findings.append(_finding("NO_TERMINAL_PATH", nodes=missing))
377
+
378
+ remaining = dict(indegree)
379
+ queue = deque(sorted(node_id for node_id, degree in remaining.items() if degree == 0))
380
+ visited = 0
381
+ while queue:
382
+ current = queue.popleft()
383
+ visited += 1
384
+ for target in adjacency[current]:
385
+ remaining[target] -= 1
386
+ if remaining[target] == 0:
387
+ queue.append(target)
388
+ if visited != len(nodes):
389
+ findings.append(
390
+ _finding(
391
+ "CONTROL_CYCLE_UNSUPPORTED",
392
+ nodes=[node_id for node_id, degree in remaining.items() if degree > 0],
393
+ )
394
+ )
395
+
396
+ unbounded_model_fanout = [
397
+ index
398
+ for index, edge in enumerate(plan.edges)
399
+ if edge.kind == "fanout" and edge.decided_by == "model" and edge.max_fan_out is None
400
+ ]
401
+ if unbounded_model_fanout:
402
+ findings.append(
403
+ _finding("MODEL_FANOUT_UNBOUNDED", edges=unbounded_model_fanout)
404
+ )
405
+
406
+ planned_calls = sum(
407
+ node.max_invocations or 0 for node in plan.nodes if node.agent_call
408
+ )
409
+ if planned_calls > plan.limits.maximum_agent_calls:
410
+ findings.append(
411
+ {
412
+ "code": "CALL_BOUND_TOO_LOW",
413
+ "declared": plan.limits.maximum_agent_calls,
414
+ "required": planned_calls,
415
+ }
416
+ )
417
+ planned_parallelism = max(
418
+ (node.max_parallelism or 0 for node in plan.nodes if node.agent_call),
419
+ default=0,
420
+ )
421
+ if planned_parallelism > plan.limits.maximum_parallelism:
422
+ findings.append(
423
+ {
424
+ "code": "PARALLEL_BOUND_TOO_LOW",
425
+ "declared": plan.limits.maximum_parallelism,
426
+ "required": planned_parallelism,
427
+ }
428
+ )
429
+ return findings
430
+
431
+
432
+ def build_control_preflight(
433
+ raw_plan: bytes,
434
+ *,
435
+ profile: str = selector.DEFAULT_PROFILE,
436
+ created_at: datetime | None = None,
437
+ ) -> dict:
438
+ """Evaluate a valid control plan as one advisory, graph-state-bound artifact."""
439
+ if profile not in selector.PROFILES:
440
+ raise ControlPlanError(
441
+ f"unknown profile {profile!r} — expected one of {sorted(selector.PROFILES)}"
442
+ )
443
+ if created_at is not None:
444
+ problem = rfc3339_offset_error(created_at)
445
+ if problem:
446
+ raise ControlPlanError(problem)
447
+ plan = load_control_plan(raw_plan)
448
+ findings = lint_control_plan(plan)
449
+
450
+ def fetch() -> dict:
451
+ evaluations = []
452
+ for principal in plan.principals:
453
+ verdict = selector.eligible_tools(
454
+ principal.id, principal.candidate_tools, profile=profile
455
+ )
456
+ evaluations.append(
457
+ {
458
+ "principal": principal.id,
459
+ "agent_found": verdict["agent_found"],
460
+ "eligible": verdict["eligible"],
461
+ "rejected": verdict["rejected"],
462
+ }
463
+ )
464
+ return {"evaluations": evaluations}
465
+
466
+ compiled = queries.with_graph_state(fetch, strict=True)
467
+ graph_state = compiled["graph_state"]
468
+ if not isinstance(graph_state.get("instance_id"), str) or not graph_state["instance_id"]:
469
+ raise ControlPlanError("graph has no instance id — run init-schema before preflight")
470
+
471
+ warned = bool(findings) or any(
472
+ not evaluation["agent_found"] or evaluation["rejected"]
473
+ for evaluation in compiled["evaluations"]
474
+ )
475
+ timestamp = created_at or datetime.now(timezone.utc)
476
+ return {
477
+ "schema_version": SCHEMA_VERSION,
478
+ "kind": RESULT_KIND,
479
+ "run_id": plan.run_id,
480
+ "created_at": timestamp.isoformat(),
481
+ "mode": "advisory",
482
+ "profile": profile,
483
+ "plan_digest": f"sha256:{sha256_bytes(raw_plan)}",
484
+ "graph_state": graph_state,
485
+ "decision": "advisory_warn" if warned else "advisory_allow",
486
+ # Redaction covers graph evidence only. Applied to the whole artifact
487
+ # it also rewrites identities: a URI-shaped run_id loses its query, and
488
+ # principals differing only there collapse into one output identity —
489
+ # which breaks the run correlation this artifact exists for. Identity
490
+ # fields are already credential-free; load_control_plan rejects a plan
491
+ # carrying credential-shaped values.
492
+ "evaluations": safe_artifact(compiled["evaluations"]),
493
+ "findings": findings,
494
+ }
File without changes