runtime-labs-contracts 0.1.0__tar.gz

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,22 @@
1
+ .venv/
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ dist/
11
+ *.jsonl
12
+ .scanner-recordings/
13
+ .scanner-cache/
14
+ .scanner-checkpoints/
15
+ .scanner-runs/
16
+
17
+ # Local app state and frontend build artifacts
18
+ .runtime-labs/
19
+ node_modules/
20
+ *.tsbuildinfo
21
+ playwright-report/
22
+ test-results/
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.5
2
+ Name: runtime-labs-contracts
3
+ Version: 0.1.0
4
+ Summary: Shared validation models and deployment contracts for Runtime Labs
5
+ Project-URL: Repository, https://github.com/arpandesai0/runtime-labs
6
+ Requires-Python: <3.14,>=3.11
7
+ Requires-Dist: pydantic<3,>=2.7
8
+ Provides-Extra: pubsub
9
+ Requires-Dist: google-cloud-pubsub<3,>=2.27; extra == 'pubsub'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Runtime Labs contracts
13
+
14
+ Shared validation models, deployment manifests, source identities, and messaging
15
+ contracts used by the Runtime Labs SDK and services.
16
+
17
+ Requires Python 3.11, 3.12, or 3.13.
18
+
19
+ ```bash
20
+ pip install runtime-labs-contracts
21
+ ```
22
+
23
+ Installing `runtime-labs-sdk` also installs this package automatically.
24
+
25
+ See the [Runtime Labs repository](https://github.com/arpandesai0/runtime-labs)
26
+ for SDK usage and development instructions.
@@ -0,0 +1,15 @@
1
+ # Runtime Labs contracts
2
+
3
+ Shared validation models, deployment manifests, source identities, and messaging
4
+ contracts used by the Runtime Labs SDK and services.
5
+
6
+ Requires Python 3.11, 3.12, or 3.13.
7
+
8
+ ```bash
9
+ pip install runtime-labs-contracts
10
+ ```
11
+
12
+ Installing `runtime-labs-sdk` also installs this package automatically.
13
+
14
+ See the [Runtime Labs repository](https://github.com/arpandesai0/runtime-labs)
15
+ for SDK usage and development instructions.
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runtime-labs-contracts"
7
+ version = "0.1.0"
8
+ description = "Shared validation models and deployment contracts for Runtime Labs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11,<3.14"
11
+ dependencies = ["pydantic>=2.7,<3"]
12
+
13
+ [project.optional-dependencies]
14
+ pubsub = ["google-cloud-pubsub>=2.27,<3"]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/arpandesai0/runtime-labs"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ only-include = ["src"]
21
+ sources = ["src"]
@@ -0,0 +1,196 @@
1
+ """The scanner's discovery output: assets, their metadata, and direct asset edges.
2
+
3
+ This is the only graph the scanner publishes for downstream consumers. The data
4
+ processing pipelines build every view (UI, evals, risk, governance) from it and
5
+ never read source code themselves.
6
+
7
+ Rules the scanner guarantees:
8
+
9
+ - Every asset and edge is backed by source evidence the verifier re-proved at the
10
+ scanned commit. Nothing comes from names, runtime traces or model confidence.
11
+ - Edges are single direct relations. A route that reaches a model through two
12
+ helper functions is three edges, not one; consumers traverse when they need to.
13
+ - ``asset_id`` is stable across scans while the asset's identity is unchanged
14
+ (kind plus symbol, registration label, route or call ordinal), so history,
15
+ diffs and trace links survive unrelated edits. Line ranges live in ``anchor``.
16
+ - Traces are not assets. A span links to an asset through ``runtime_ids``, the
17
+ deployment-manifest node IDs the SDK stamps on spans.
18
+ """
19
+
20
+ from typing import Any, Literal
21
+
22
+ from contracts import SourceLocation, StrictModel
23
+ from pydantic import Field, model_validator
24
+
25
+ AssetKind = Literal[
26
+ "Endpoint", # an HTTP route handler
27
+ "Workflow", # a compiled agent graph (identified by the factory that builds it)
28
+ "WorkflowNode", # one registered node of a workflow
29
+ "Agent", # an agent object outside a workflow framework
30
+ "Tool", # a callable exposed to a model
31
+ "ModelCall", # one model invocation site
32
+ "DataStore", # a persisted entity such as an ORM model
33
+ "Function", # plain code that directly links other assets
34
+ ]
35
+
36
+ EdgeKind = Literal[
37
+ "contains", # Workflow -> WorkflowNode
38
+ "starts", # Workflow -> WorkflowNode it enters first (-> itself: may end at once)
39
+ "ends", # WorkflowNode -> Workflow it can finish
40
+ "transitions_to", # WorkflowNode -> WorkflowNode (control flow)
41
+ "delegates_to", # WorkflowNode -> Workflow it runs as a subgraph
42
+ "invokes", # direct call between code assets
43
+ "calls_model", # asset whose body contains the ModelCall
44
+ "offers_tool", # ModelCall -> Tool passed to it, or Agent -> Tool its model can run
45
+ "reads", # asset -> DataStore it queries
46
+ ]
47
+
48
+
49
+ class SourceAnchor(StrictModel):
50
+ path: str
51
+ location: SourceLocation
52
+
53
+
54
+ class _Metadata(StrictModel):
55
+ description: str | None = None
56
+ signature: str | None = None
57
+ return_annotation: str | None = None
58
+ asynchronous: bool | None = None
59
+
60
+
61
+ class EndpointMetadata(_Metadata):
62
+ method: str
63
+ route: str
64
+ handler: str
65
+ framework: str | None = None # the library spec that recognized the route
66
+
67
+
68
+ class WorkflowMetadata(_Metadata):
69
+ factory: str
70
+ state_type: str | None = None
71
+ framework: str | None = None # the library spec that recognized the graph
72
+
73
+
74
+ class WorkflowNodeMetadata(_Metadata):
75
+ label: str
76
+ callback: str
77
+ subgraph: bool = False
78
+ state_type: str | None = None
79
+
80
+
81
+ class AgentMetadata(_Metadata):
82
+ rule: str | None = None
83
+ # True for code that makes one model call and uses the answer (a summary or a
84
+ # classification), rather than looping on the model's output.
85
+ single_call: bool | None = None
86
+ # What the code asks the model, for summaries: its bounded source, enclosing
87
+ # docstrings and the text constants (prompts, questions) it uses.
88
+ context: dict[str, Any] | None = None
89
+
90
+
91
+ class ToolMetadata(_Metadata):
92
+ rule: str | None = None
93
+
94
+
95
+ class ModelCallMetadata(_Metadata):
96
+ """AI configuration as source values: each says whether it is a literal, a
97
+ template with variables, or an unresolved expression. The description and
98
+ signature are the enclosing function's."""
99
+
100
+ enclosing_symbol: str
101
+ call_ordinal: int | None = None
102
+ provider: str | None = None
103
+ operation: str | None = None
104
+ # The function that makes the provider call, when this site reaches it through
105
+ # the application's own wrappers (``through``, outermost first).
106
+ boundary: str | None = None
107
+ through: list[str] = Field(default_factory=list)
108
+ model: dict[str, Any] | None = None
109
+ prompts: list[dict[str, Any]] = Field(default_factory=list)
110
+ tools: dict[str, Any] | None = None
111
+ settings: dict[str, Any] = Field(default_factory=dict)
112
+ configuration: dict[str, Any] | None = None
113
+
114
+
115
+ class DataStoreMetadata(_Metadata):
116
+ framework: str | None = None
117
+
118
+
119
+ class FunctionMetadata(_Metadata):
120
+ pass
121
+
122
+
123
+ METADATA = {
124
+ "Endpoint": EndpointMetadata,
125
+ "Workflow": WorkflowMetadata,
126
+ "WorkflowNode": WorkflowNodeMetadata,
127
+ "Agent": AgentMetadata,
128
+ "Tool": ToolMetadata,
129
+ "ModelCall": ModelCallMetadata,
130
+ "DataStore": DataStoreMetadata,
131
+ "Function": FunctionMetadata,
132
+ }
133
+
134
+
135
+ class Asset(StrictModel):
136
+ asset_id: str
137
+ kind: AssetKind
138
+ name: str
139
+ qualified_symbol: str
140
+ parent_id: str | None = None
141
+ anchor: SourceAnchor
142
+ evidence_ids: list[str] = Field(min_length=1)
143
+ runtime_ids: list[str] = Field(default_factory=list)
144
+ metadata: dict[str, Any] = Field(default_factory=dict)
145
+
146
+ @model_validator(mode="after")
147
+ def typed_metadata(self):
148
+ self.metadata = METADATA[self.kind].model_validate(self.metadata).model_dump(
149
+ exclude_none=True
150
+ )
151
+ return self
152
+
153
+
154
+ class AssetEdge(StrictModel):
155
+ edge_id: str
156
+ kind: EdgeKind
157
+ from_asset_id: str
158
+ to_asset_id: str
159
+ evidence_ids: list[str] = Field(min_length=1)
160
+ attributes: dict[str, Any] = Field(default_factory=dict)
161
+
162
+
163
+ class DiscoveryGap(StrictModel):
164
+ """Something the scanner saw but could not prove, kept so coverage is honest."""
165
+
166
+ code: str
167
+ message: str
168
+ anchor: SourceAnchor | None = None
169
+
170
+
171
+ class SourceRemote(StrictModel):
172
+ """Where the scanned checkout lives on its Git host, so consumers can link to source."""
173
+
174
+ url: str # normalized remote, e.g. "github.com/acme/app"
175
+ subdirectory: str = "" # the scanned directory within that repository; "" at its root
176
+
177
+
178
+ class AssetGraph(StrictModel):
179
+ schema_version: Literal[1] = 1
180
+ repository_id: str
181
+ commit_sha: str
182
+ remote: SourceRemote | None = None
183
+ scanner_version: str
184
+ assets: list[Asset]
185
+ edges: list[AssetEdge]
186
+ gaps: list[DiscoveryGap] = Field(default_factory=list)
187
+
188
+ @model_validator(mode="after")
189
+ def closed_graph(self):
190
+ ids = {asset.asset_id for asset in self.assets}
191
+ if len(ids) != len(self.assets):
192
+ raise ValueError("Duplicate asset IDs")
193
+ for edge in self.edges:
194
+ if edge.from_asset_id not in ids or edge.to_asset_id not in ids:
195
+ raise ValueError(f"Edge {edge.edge_id} references an unknown asset")
196
+ return self
@@ -0,0 +1,150 @@
1
+ """Versioned wire contracts for source discovery and runtime attribution.
2
+
3
+ These models are shared across scanner output, instrumentation manifests, and
4
+ trace correlation. Extra fields are rejected so incompatible producers cannot
5
+ silently change the meaning of a stored graph or span identity.
6
+ """
7
+
8
+ from typing import Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
11
+
12
+
13
+ class StrictModel(BaseModel):
14
+ """Base contract that rejects fields outside the declared schema."""
15
+
16
+ model_config = ConfigDict(extra="forbid")
17
+
18
+
19
+ NodeKind = Literal["Agent", "Tool", "Workflow", "ModelCallSite", "Model", "Provider", "Service"]
20
+ EdgeKind = Literal[
21
+ "DEFINED_IN",
22
+ "CONTAINS",
23
+ "REGISTERS_TOOL",
24
+ "INVOKES_AGENT",
25
+ "INVOKES_TOOL",
26
+ "CALLS_MODEL",
27
+ ]
28
+ EvidenceKind = Literal["declared", "statically_inferred"]
29
+ SupportState = Literal["supported", "unresolved", "unsupported"]
30
+
31
+
32
+ class SourceLocation(StrictModel):
33
+ """One-based source line range with zero-based columns."""
34
+
35
+ start_line: int = Field(ge=1)
36
+ start_col: int = Field(ge=0)
37
+ end_line: int = Field(ge=1)
38
+ end_col: int = Field(ge=0)
39
+
40
+
41
+ class Evidence(StrictModel):
42
+ """The scanner rule and source range supporting a discovered fact."""
43
+
44
+ rule: str
45
+ adapter: str
46
+ classification: EvidenceKind
47
+ path: str
48
+ location: SourceLocation
49
+
50
+
51
+ class SourceNode(StrictModel):
52
+ """A source-level component or call site tied to its file and fingerprint."""
53
+
54
+ node_id: str
55
+ kind: NodeKind
56
+ qualified_symbol: str
57
+ path: str
58
+ location: SourceLocation
59
+ source_digest: str
60
+ semantic_fingerprint: str
61
+ operation: str | None = None
62
+ call_ordinal: int | None = Field(default=None, ge=1)
63
+ support: SupportState = "supported"
64
+ evidence: list[Evidence] = Field(min_length=1)
65
+
66
+
67
+ class SourceEdge(StrictModel):
68
+ """A directed relationship between two nodes in the same source graph."""
69
+
70
+ from_node_id: str
71
+ to_node_id: str
72
+ kind: EdgeKind
73
+ evidence: Evidence
74
+
75
+
76
+ class ScanDiagnostic(StrictModel):
77
+ """A skipped, unsupported, or unresolved source pattern reported by a scan."""
78
+
79
+ code: str
80
+ path: str
81
+ message: str
82
+ location: SourceLocation | None = None
83
+
84
+
85
+ class SourceGraph(StrictModel):
86
+ """The scanner's versioned graph for one repository snapshot."""
87
+
88
+ schema_version: Literal[1] = 1
89
+ project_id: str
90
+ repository_id: str
91
+ graph_version_id: str
92
+ commit_sha: str | None
93
+ dirty: bool
94
+ scanner_version: str
95
+ nodes: list[SourceNode]
96
+ edges: list[SourceEdge]
97
+ diagnostics: list[ScanDiagnostic] = Field(default_factory=list)
98
+
99
+ @model_validator(mode="after")
100
+ def check_references(self) -> "SourceGraph":
101
+ """Reject duplicate nodes and edges that point outside this graph."""
102
+
103
+ ids = [node.node_id for node in self.nodes]
104
+ if len(ids) != len(set(ids)):
105
+ raise ValueError("Duplicate source node IDs")
106
+ known = set(ids)
107
+ for edge in self.edges:
108
+ if edge.from_node_id not in known or edge.to_node_id not in known:
109
+ raise ValueError("Source edge references an absent node")
110
+ return self
111
+
112
+
113
+ class ManifestTarget(StrictModel):
114
+ """A source node and trusted SDK hook selected for instrumentation."""
115
+
116
+ node_id: str
117
+ kind: NodeKind
118
+ strategy: Literal["provider_hook", "framework_hook", "function_hook"]
119
+ hook_id: str
120
+ qualified_symbol: str
121
+ source_digest: str
122
+ expected_span_kind: str
123
+
124
+
125
+ class InstrumentationManifest(StrictModel):
126
+ """A revision-bound set of hooks for a supported customer deployment."""
127
+
128
+ schema_version: Literal[1] = 1
129
+ project_id: str
130
+ repository_id: str
131
+ graph_version_id: str
132
+ commit_sha: str
133
+ manifest_id: str
134
+ min_sdk_version: str
135
+ targets: list[ManifestTarget]
136
+
137
+
138
+ class SpanIdentity(StrictModel):
139
+ """Identifiers needed to link an observed span to its source revision."""
140
+
141
+ schema_version: Literal[1] = 1
142
+ project_id: str
143
+ repository_id: str | None = None
144
+ commit_sha: str | None = None
145
+ graph_version_id: str | None = None
146
+ manifest_id: str | None = None
147
+ node_id: str | None = None
148
+ trace_id: str
149
+ span_id: str
150
+ parent_span_id: str | None = None
@@ -0,0 +1,102 @@
1
+ """Metadata-only source identities for future runtime telemetry attribution."""
2
+
3
+ from pathlib import PurePosixPath
4
+ from typing import Literal
5
+
6
+ from contracts import SourceLocation, StrictModel
7
+ from pydantic import Field, field_validator, model_validator
8
+
9
+ RuntimeEntityKind = Literal["Workflow", "Agent", "Tool", "ModelCallSite", "APIEndpoint"]
10
+ RuntimeRelationshipKind = Literal["CONTAINS", "REGISTERS_TOOL", "CALLS_MODEL", "ROUTES_TO"]
11
+
12
+
13
+ class ManifestEvidence(StrictModel):
14
+ """Source proof reference; its revision is the enclosing manifest's revision."""
15
+
16
+ evidence_id: str
17
+ path: str
18
+ location: SourceLocation
19
+ source_digest: str = Field(pattern=r"^[0-9a-f]{64}$")
20
+ adapter: str
21
+ adapter_version: str
22
+ verifier_version: str
23
+
24
+ @field_validator("path")
25
+ @classmethod
26
+ def repository_relative_path(cls, value):
27
+ path = PurePosixPath(value)
28
+ if path.is_absolute() or ".." in path.parts or "\\" in value or not path.parts:
29
+ raise ValueError("Manifest source paths must be repository relative")
30
+ return value
31
+
32
+ @model_validator(mode="after")
33
+ def ordered_range(self):
34
+ loc = self.location
35
+ if (loc.end_line, loc.end_col) <= (loc.start_line, loc.start_col):
36
+ raise ValueError("Manifest source ranges must be nonempty and ordered")
37
+ return self
38
+
39
+
40
+ class ManifestEntity(StrictModel):
41
+ node_id: str
42
+ kind: RuntimeEntityKind
43
+ qualified_symbol: str
44
+ source: ManifestEvidence
45
+ evidence_ids: list[str] = Field(min_length=1)
46
+ verification_status: Literal["verified_source"] = "verified_source"
47
+ framework: Literal["langgraph", "fastapi"] | None = None
48
+ provider: Literal["google"] | None = None
49
+ operation: str | None = None
50
+ model: str | None = None
51
+
52
+ @model_validator(mode="after")
53
+ def call_site_metadata(self):
54
+ if self.kind == "ModelCallSite":
55
+ if not self.provider:
56
+ raise ValueError("Model call sites require a verified provider")
57
+ elif any(value is not None for value in (self.model, self.operation)):
58
+ raise ValueError("Only model call sites may carry call-site metadata")
59
+ return self
60
+
61
+
62
+ class ManifestRelationship(StrictModel):
63
+ relationship_id: str
64
+ kind: RuntimeRelationshipKind
65
+ from_node_id: str
66
+ to_node_id: str
67
+ evidence_ids: list[str] = Field(min_length=1)
68
+ verification_status: Literal["verified_source"] = "verified_source"
69
+
70
+
71
+ class DeploymentManifest(StrictModel):
72
+ """V1 deployment metadata compiled solely from a published EvidenceGraph."""
73
+
74
+ schema_version: Literal[1] = 1
75
+ repository_id: str
76
+ commit_sha: str
77
+ scanner_version: str
78
+ entities: list[ManifestEntity]
79
+ relationships: list[ManifestRelationship]
80
+ evidence: list[ManifestEvidence]
81
+
82
+ @model_validator(mode="after")
83
+ def check_references(self):
84
+ nodes = {entity.node_id: entity for entity in self.entities}
85
+ proof = {item.evidence_id: item for item in self.evidence}
86
+ if len(nodes) != len(self.entities) or len(proof) != len(self.evidence):
87
+ raise ValueError("Duplicate manifest node or evidence IDs")
88
+ if len({r.relationship_id for r in self.relationships}) != len(self.relationships):
89
+ raise ValueError("Duplicate manifest relationship IDs")
90
+ for item in [*self.entities, *self.relationships]:
91
+ if not set(item.evidence_ids) <= proof.keys():
92
+ raise ValueError("Unknown manifest evidence reference")
93
+ for entity in self.entities:
94
+ if (
95
+ proof.get(entity.source.evidence_id) != entity.source
96
+ or entity.source.evidence_id not in entity.evidence_ids
97
+ ):
98
+ raise ValueError("Entity source must reference its manifest evidence")
99
+ for relationship in self.relationships:
100
+ if relationship.from_node_id not in nodes or relationship.to_node_id not in nodes:
101
+ raise ValueError("Manifest relationship references an absent node")
102
+ return self
@@ -0,0 +1,256 @@
1
+ """V2 evidence contracts; v1 source graphs and runtime span contracts stay unchanged."""
2
+
3
+ from typing import Any, Literal
4
+
5
+ from contracts import SourceLocation, StrictModel
6
+ from pydantic import Field, model_validator
7
+
8
+ ClaimStatus = Literal["proposed", "verified_source", "partial", "unresolved", "contradicted"]
9
+ Transform = Literal[
10
+ "field_copy",
11
+ "argument_binding",
12
+ "string_format",
13
+ "lookup",
14
+ "serialization",
15
+ "derived_output",
16
+ "return_binding",
17
+ "state_update",
18
+ "state_read",
19
+ "transformation",
20
+ ]
21
+
22
+
23
+ class EvidenceRef(StrictModel):
24
+ evidence_id: str
25
+ evidence_type: Literal["source", "runtime", "synthetic_test"] = "source"
26
+ repository_id: str
27
+ commit_sha: str
28
+ path: str
29
+ file_digest: str
30
+ location: SourceLocation
31
+ adapter: str
32
+ adapter_version: str = "2"
33
+ verifier_version: str = "2"
34
+ task_id: str = "index"
35
+ deployment_id: str | None = None
36
+ span_id: str | None = None
37
+ test_id: str | None = None
38
+
39
+ @model_validator(mode="after")
40
+ def check_evidence_type(self):
41
+ if self.evidence_type == "runtime" and not (self.deployment_id and self.span_id):
42
+ raise ValueError("Runtime evidence requires deployment and span IDs")
43
+ if self.evidence_type == "synthetic_test" and not self.test_id:
44
+ raise ValueError("Synthetic evidence requires a test ID")
45
+ return self
46
+
47
+
48
+ class Port(StrictModel):
49
+ port_id: str
50
+ symbol_id: str
51
+ name: str
52
+ kind: Literal["parameter", "field", "value", "argument", "return", "state", "model_output"]
53
+ evidence: EvidenceRef
54
+
55
+
56
+ class IndexSymbol(StrictModel):
57
+ symbol_id: str
58
+ qualified_name: str
59
+ kind: str
60
+ parameters: list[str] = Field(default_factory=list)
61
+ parameter_types: dict[str, str] = Field(default_factory=dict)
62
+ return_type: str | None = None
63
+ bases: list[str] = Field(default_factory=list)
64
+ metadata: dict[str, Any] = Field(default_factory=dict)
65
+ evidence: EvidenceRef
66
+
67
+
68
+ class IndexFact(StrictModel):
69
+ """Language-neutral syntax/semantic facts; only adapters may produce these."""
70
+
71
+ fact_id: str
72
+ kind: Literal[
73
+ "import",
74
+ "reference",
75
+ "call",
76
+ "assignment",
77
+ "argument_binding",
78
+ "return",
79
+ "flow",
80
+ "asset",
81
+ "relationship",
82
+ "registration",
83
+ "gap",
84
+ ]
85
+ symbol_id: str
86
+ source: str = ""
87
+ target: str = ""
88
+ transform: Transform | None = None
89
+ guard: str | None = None
90
+ attributes: dict[str, Any] = Field(default_factory=dict)
91
+ evidence: list[EvidenceRef] = Field(min_length=1)
92
+
93
+
94
+ class IndexBundle(StrictModel):
95
+ schema_version: Literal[2] = 2
96
+ repository_id: str
97
+ commit_sha: str
98
+ files: dict[str, str]
99
+ symbols: list[IndexSymbol]
100
+ ports: list[Port]
101
+ facts: list[IndexFact]
102
+ diagnostics: list[str] = Field(default_factory=list)
103
+ cache_hits: int = 0
104
+
105
+
106
+ class Gap(StrictModel):
107
+ code: str
108
+ message: str
109
+ evidence: list[EvidenceRef] = Field(default_factory=list)
110
+ after_port: str | None = None
111
+ before_port: str | None = None
112
+
113
+
114
+ class VerificationResult(StrictModel):
115
+ status: ClaimStatus
116
+ verifier_version: str = "2"
117
+ evidence: list[EvidenceRef] = Field(default_factory=list)
118
+ gaps: list[Gap] = Field(default_factory=list)
119
+
120
+ @model_validator(mode="after")
121
+ def source_proof_requires_evidence(self):
122
+ if self.status == "verified_source" and (
123
+ not self.evidence
124
+ or self.gaps
125
+ or any(ev.evidence_type != "source" for ev in self.evidence)
126
+ ):
127
+ raise ValueError("Source verification requires source evidence and no gaps")
128
+ return self
129
+
130
+
131
+ class CandidateClaim(StrictModel):
132
+ claim_id: str
133
+ task_id: str
134
+ kind: Literal["asset", "relationship", "flow"]
135
+ fact_id: str
136
+ source: str
137
+ target: str
138
+ transform: Transform | None = None
139
+ guard: str | None = None
140
+ attributes: dict[str, Any] = Field(default_factory=dict)
141
+ evidence: list[EvidenceRef] = Field(min_length=1)
142
+ status: Literal["proposed"] = "proposed"
143
+
144
+
145
+ class FlowHop(StrictModel):
146
+ hop_id: str
147
+ origin_port: str
148
+ target_port: str
149
+ transform: Transform
150
+ guard: str | None = None
151
+ verification: VerificationResult
152
+
153
+
154
+ class FlowPath(StrictModel):
155
+ flow_path_id: str
156
+ origin_port: str
157
+ sink_port: str
158
+ labels: list[str] = Field(default_factory=list)
159
+ hops: list[FlowHop]
160
+ status: ClaimStatus
161
+ gaps: list[Gap] = Field(default_factory=list)
162
+
163
+ @model_validator(mode="after")
164
+ def check_complete_path(self):
165
+ continuous = bool(self.hops) and self.hops[0].origin_port == self.origin_port
166
+ continuous = continuous and self.hops[-1].target_port == self.sink_port
167
+ continuous = continuous and all(
168
+ left.target_port == right.origin_port
169
+ for left, right in zip(self.hops, self.hops[1:], strict=False)
170
+ )
171
+ if self.status == "verified_source" and (
172
+ not continuous
173
+ or self.gaps
174
+ or any(h.verification.status != "verified_source" for h in self.hops)
175
+ ):
176
+ raise ValueError("A verified path requires a continuous, fully verified chain")
177
+ if self.status in {"partial", "unresolved"} and not self.gaps:
178
+ raise ValueError("An incomplete path requires a named gap")
179
+ return self
180
+
181
+
182
+ class Budget(StrictModel):
183
+ max_steps: int = Field(default=12, ge=0)
184
+ max_tokens: int = Field(default=250000, ge=0)
185
+ max_seconds: float = Field(default=600, ge=0)
186
+
187
+
188
+ class InvestigationTask(StrictModel):
189
+ task_id: str
190
+ repository_id: str
191
+ commit_sha: str
192
+ task_type: Literal["asset", "relationship", "flow"]
193
+ objective: str
194
+ anchors: list[str] = Field(default_factory=list)
195
+ relevant_files: list[str] = Field(default_factory=list)
196
+ hypothesis: str | None = None
197
+ stopping_condition: str = "Verified adjacent claims, explicit abstention, or budget exhaustion"
198
+ allowed_tools: list[str] = Field(
199
+ default_factory=lambda: [
200
+ "repo_map",
201
+ "find_symbols",
202
+ "find_references",
203
+ "read_symbol",
204
+ "read_source_window",
205
+ "find_registrations",
206
+ "get_flow_slice",
207
+ "submit_claim",
208
+ ]
209
+ )
210
+ budget: Budget = Field(default_factory=Budget)
211
+ steps: int = 0
212
+ tokens: int = 0
213
+ elapsed_seconds: float = 0
214
+ failed_verifications: int = 0
215
+ status: Literal["pending", "running", "complete", "budget_exhausted", "unresolved"] = "pending"
216
+ claims: list[CandidateClaim] = Field(default_factory=list)
217
+ results: list[VerificationResult] = Field(default_factory=list)
218
+ gaps: list[Gap] = Field(default_factory=list)
219
+ model_events: list[dict[str, Any]] = Field(default_factory=list)
220
+ context: dict[str, Any] = Field(default_factory=dict)
221
+ routing_hints: dict[str, Any] = Field(default_factory=dict)
222
+
223
+
224
+ class EvidenceGraph(StrictModel):
225
+ schema_version: Literal[2] = 2
226
+ repository_id: str
227
+ commit_sha: str
228
+ graph_version_id: str
229
+ ports: list[Port]
230
+ assets: list[CandidateClaim]
231
+ relationships: list[CandidateClaim]
232
+ verifications: dict[str, VerificationResult]
233
+ hops: list[FlowHop]
234
+ paths: list[FlowPath]
235
+ gaps: list[Gap]
236
+ tasks: list[InvestigationTask] = Field(default_factory=list)
237
+
238
+ @model_validator(mode="after")
239
+ def check_links(self):
240
+ ids = [p.port_id for p in self.ports]
241
+ if len(ids) != len(set(ids)):
242
+ raise ValueError("Duplicate port IDs")
243
+ for hop in self.hops:
244
+ if hop.origin_port not in ids or hop.target_port not in ids:
245
+ raise ValueError("Unknown hop port")
246
+ for claim in [*self.assets, *self.relationships]:
247
+ if claim.claim_id not in self.verifications:
248
+ raise ValueError("Missing claim verification")
249
+ for ev in [p.evidence for p in self.ports]:
250
+ if (ev.repository_id, ev.commit_sha) != (self.repository_id, self.commit_sha):
251
+ raise ValueError("Port belongs to another source revision")
252
+ known_hops = {hop.hop_id: hop for hop in self.hops}
253
+ for path in self.paths:
254
+ if any(known_hops.get(hop.hop_id) != hop for hop in path.hops):
255
+ raise ValueError("Path references an absent or altered hop")
256
+ return self
@@ -0,0 +1,341 @@
1
+ """Scan-completed events and the publish/subscribe transport that carries them.
2
+
3
+ The scanner publishes to one topic after its artifacts are on disk. The message
4
+ is a claim check: it names the scanner's output files (``assets.json`` and the
5
+ deployment manifest) rather than embedding them, so the topic stays small and the
6
+ immutable artifacts remain the source of truth.
7
+ Consumers pull from their own subscription, so several pipelines can read the
8
+ same topic independently.
9
+ """
10
+
11
+ import os
12
+ import queue
13
+ import sqlite3
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Callable, Protocol
20
+
21
+ from contracts import StrictModel
22
+ from pydantic import Field
23
+
24
+ SCAN_COMPLETED_TOPIC = "runtime-labs-scan-completed"
25
+ PIPELINE_SUBSCRIPTION = "data-processing-pipelines"
26
+ SPANS_INGESTED_TOPIC = "runtime-labs-spans-ingested"
27
+ CORRELATION_SUBSCRIPTION = "data-processing-pipelines-correlation"
28
+ CORRELATION_PREFIX = "runtime_labs.ctx."
29
+
30
+
31
+ class ScanCompleted(StrictModel):
32
+ event_id: str = Field(min_length=1)
33
+ workspace_repository_id: str = Field(min_length=1)
34
+ scan_id: str = Field(min_length=1)
35
+ source_repository_id: str
36
+ commit_sha: str
37
+ assets_path: str # the scanner's asset graph; consumers never read source
38
+ manifest_path: str
39
+ published_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
40
+
41
+
42
+ class IngestedSpan(StrictModel):
43
+ """A span as the pipelines receive it: the raw span plus the facts they correlate on."""
44
+
45
+ trace_id: str
46
+ span_id: str
47
+ parent_span_id: str | None = None
48
+ start_time_ns: int | None = None
49
+ end_time_ns: int | None = None
50
+ node_id: str | None = None
51
+ # The stored span as received, so the pipelines can build trace views from it.
52
+ payload: dict | None = None
53
+ # Raw ``runtime_labs.ctx.*`` attributes without the prefix, e.g.
54
+ # {"langgraph.thread_id": "conv-42"}. Never interpreted by the SDK or the API.
55
+ identifiers: dict[str, str] = Field(default_factory=dict)
56
+
57
+
58
+ class SpansIngested(StrictModel):
59
+ event_id: str = Field(min_length=1)
60
+ workspace_repository_id: str = Field(min_length=1)
61
+ spans: list[IngestedSpan] = Field(min_length=1)
62
+ published_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
63
+
64
+ @classmethod
65
+ def from_spans(cls, event_id, workspace_repository_id, spans):
66
+ """Build from stored span dicts (trace_id, span_id, attributes, ...)."""
67
+ facts = []
68
+ for span in spans:
69
+ attributes = span.get("attributes") or {}
70
+ facts.append(
71
+ IngestedSpan(
72
+ trace_id=span["trace_id"],
73
+ span_id=span["span_id"],
74
+ parent_span_id=span.get("parent_span_id"),
75
+ start_time_ns=span.get("start_time_ns"),
76
+ end_time_ns=span.get("end_time_ns"),
77
+ node_id=attributes.get("runtime_labs.node_id"),
78
+ payload=span,
79
+ identifiers={
80
+ key.removeprefix(CORRELATION_PREFIX): str(value)
81
+ for key, value in attributes.items()
82
+ if key.startswith(CORRELATION_PREFIX) and value not in (None, "")
83
+ },
84
+ )
85
+ )
86
+ return cls(event_id=event_id, workspace_repository_id=workspace_repository_id, spans=facts)
87
+
88
+
89
+ @dataclass
90
+ class Received:
91
+ """One delivered message. Ack removes it; nack makes it deliverable again."""
92
+
93
+ data: bytes
94
+ ack: Callable[[], None]
95
+ nack: Callable[[], None]
96
+
97
+
98
+ class Broker(Protocol):
99
+ def ensure(self, topic: str, subscription: str | None = None) -> None: ...
100
+
101
+ def publish(self, topic: str, data: bytes) -> None: ...
102
+
103
+ def pull(
104
+ self, subscription: str, *, max_messages: int = 10, timeout: float = 5.0
105
+ ) -> list[Received]: ...
106
+
107
+
108
+ class MemoryBroker:
109
+ """Process-local broker with fan-out per subscription, for tests and demos."""
110
+
111
+ def __init__(self):
112
+ self._topics: dict[str, list[str]] = {}
113
+ self._queues: dict[str, queue.Queue] = {}
114
+ self._lock = threading.Lock()
115
+
116
+ def ensure(self, topic, subscription=None):
117
+ with self._lock:
118
+ subscriptions = self._topics.setdefault(topic, [])
119
+ if subscription and subscription not in subscriptions:
120
+ subscriptions.append(subscription)
121
+ self._queues[subscription] = queue.Queue()
122
+
123
+ def publish(self, topic, data):
124
+ with self._lock:
125
+ if topic not in self._topics:
126
+ raise KeyError(f"Unknown topic {topic}")
127
+ targets = [self._queues[name] for name in self._topics[topic]]
128
+ for target in targets:
129
+ target.put(data)
130
+
131
+ def pull(self, subscription, *, max_messages=10, timeout=5.0):
132
+ source = self._queues[subscription]
133
+ received = []
134
+ try:
135
+ while len(received) < max_messages:
136
+ data = source.get(timeout=timeout if not received else 0)
137
+ received.append(
138
+ Received(data, ack=lambda: None, nack=lambda data=data: source.put(data))
139
+ )
140
+ except queue.Empty:
141
+ pass
142
+ return received
143
+
144
+
145
+ class PubSubBroker:
146
+ """Google Cloud Pub/Sub. ``PUBSUB_EMULATOR_HOST`` selects the local emulator."""
147
+
148
+ def __init__(self, project: str | None = None):
149
+ from google.cloud import pubsub_v1
150
+
151
+ self.project = (
152
+ project
153
+ or os.environ.get("RUNTIME_LABS_PUBSUB_PROJECT")
154
+ or os.environ.get("GOOGLE_CLOUD_PROJECT")
155
+ or "runtime-labs-local"
156
+ )
157
+ self._publisher = pubsub_v1.PublisherClient()
158
+ self._subscriber = pubsub_v1.SubscriberClient()
159
+
160
+ def _topic(self, topic):
161
+ return self._publisher.topic_path(self.project, topic)
162
+
163
+ def _subscription(self, subscription):
164
+ return self._subscriber.subscription_path(self.project, subscription)
165
+
166
+ def ensure(self, topic, subscription=None):
167
+ from google.api_core.exceptions import AlreadyExists
168
+
169
+ try:
170
+ self._publisher.create_topic(request={"name": self._topic(topic)})
171
+ except AlreadyExists:
172
+ pass
173
+ if subscription:
174
+ try:
175
+ self._subscriber.create_subscription(
176
+ request={
177
+ "name": self._subscription(subscription),
178
+ "topic": self._topic(topic),
179
+ "ack_deadline_seconds": 60,
180
+ }
181
+ )
182
+ except AlreadyExists:
183
+ pass
184
+
185
+ def publish(self, topic, data):
186
+ self._publisher.publish(self._topic(topic), data).result(timeout=30)
187
+
188
+ def pull(self, subscription, *, max_messages=10, timeout=5.0):
189
+ from google.api_core.exceptions import DeadlineExceeded
190
+
191
+ name = self._subscription(subscription)
192
+ try:
193
+ response = self._subscriber.pull(
194
+ request={"subscription": name, "max_messages": max_messages},
195
+ timeout=timeout,
196
+ )
197
+ except DeadlineExceeded:
198
+ return []
199
+
200
+ def ack(ack_id):
201
+ self._subscriber.acknowledge(request={"subscription": name, "ack_ids": [ack_id]})
202
+
203
+ def nack(ack_id):
204
+ self._subscriber.modify_ack_deadline(
205
+ request={"subscription": name, "ack_ids": [ack_id], "ack_deadline_seconds": 0}
206
+ )
207
+
208
+ return [
209
+ Received(
210
+ m.message.data,
211
+ ack=lambda i=m.ack_id: ack(i),
212
+ nack=lambda i=m.ack_id: nack(i),
213
+ )
214
+ for m in response.received_messages
215
+ ]
216
+
217
+
218
+ class LocalBroker:
219
+ """Durable broker in one SQLite file, safe across processes. No services to run.
220
+
221
+ Same semantics as the topic/subscription model: every subscription that exists
222
+ when a message is published gets its own copy; unacked messages are redelivered
223
+ once their lease expires.
224
+ """
225
+
226
+ LEASE_SECONDS = 60
227
+
228
+ def __init__(self, path):
229
+ self.path = str(path)
230
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
231
+ with self._db() as db:
232
+ db.executescript(
233
+ """
234
+ PRAGMA journal_mode=WAL;
235
+ CREATE TABLE IF NOT EXISTS subscriptions (
236
+ topic TEXT NOT NULL, name TEXT NOT NULL, PRIMARY KEY(topic, name));
237
+ CREATE TABLE IF NOT EXISTS topics (name TEXT PRIMARY KEY);
238
+ CREATE TABLE IF NOT EXISTS messages (
239
+ id INTEGER PRIMARY KEY AUTOINCREMENT, subscription TEXT NOT NULL,
240
+ data BLOB NOT NULL, leased_until REAL NOT NULL DEFAULT 0);
241
+ """
242
+ )
243
+
244
+ def _db(self):
245
+ db = sqlite3.connect(self.path, timeout=30)
246
+ return _Transaction(db)
247
+
248
+ def ensure(self, topic, subscription=None):
249
+ with self._db() as db:
250
+ db.execute("INSERT OR IGNORE INTO topics VALUES(?)", (topic,))
251
+ if subscription:
252
+ db.execute("INSERT OR IGNORE INTO subscriptions VALUES(?,?)", (topic, subscription))
253
+
254
+ def publish(self, topic, data):
255
+ with self._db() as db:
256
+ if not db.execute("SELECT 1 FROM topics WHERE name=?", (topic,)).fetchone():
257
+ raise KeyError(f"Unknown topic {topic}")
258
+ names = [
259
+ r[0] for r in db.execute("SELECT name FROM subscriptions WHERE topic=?", (topic,))
260
+ ]
261
+ db.executemany(
262
+ "INSERT INTO messages(subscription, data) VALUES(?,?)", [(n, data) for n in names]
263
+ )
264
+
265
+ def pull(self, subscription, *, max_messages=10, timeout=5.0):
266
+ deadline = time.monotonic() + timeout
267
+ while True:
268
+ now = time.time()
269
+ with self._db() as db:
270
+ rows = db.execute(
271
+ "SELECT id, data FROM messages WHERE subscription=? AND leased_until<=? "
272
+ "ORDER BY id LIMIT ?",
273
+ (subscription, now, max_messages),
274
+ ).fetchall()
275
+ db.executemany(
276
+ "UPDATE messages SET leased_until=? WHERE id=?",
277
+ [(now + self.LEASE_SECONDS, r[0]) for r in rows],
278
+ )
279
+ if rows or time.monotonic() >= deadline:
280
+ break
281
+ time.sleep(0.25)
282
+
283
+ def ack(message_id):
284
+ with self._db() as db:
285
+ db.execute("DELETE FROM messages WHERE id=?", (message_id,))
286
+
287
+ def nack(message_id):
288
+ with self._db() as db:
289
+ db.execute("UPDATE messages SET leased_until=0 WHERE id=?", (message_id,))
290
+
291
+ return [
292
+ Received(data, ack=lambda i=i: ack(i), nack=lambda i=i: nack(i)) for i, data in rows
293
+ ]
294
+
295
+
296
+ class _Transaction:
297
+ """Commit on success, always close (sqlite3's own context manager never closes)."""
298
+
299
+ def __init__(self, db):
300
+ self.db = db
301
+
302
+ def __enter__(self):
303
+ return self.db
304
+
305
+ def __exit__(self, exc_type, *_):
306
+ try:
307
+ self.db.rollback() if exc_type else self.db.commit()
308
+ finally:
309
+ self.db.close()
310
+
311
+
312
+ def local_data_dir() -> Path:
313
+ return Path(os.environ.get("RUNTIME_LABS_DATA", ".runtime-labs")).resolve()
314
+
315
+
316
+ _default_broker = None
317
+
318
+
319
+ def broker_from_environment() -> Broker:
320
+ """Local SQLite queue by default; ``RUNTIME_LABS_BROKER=pubsub`` selects Pub/Sub
321
+ (the emulator when ``PUBSUB_EMULATOR_HOST`` is set). One broker per process."""
322
+ global _default_broker
323
+ if _default_broker is None:
324
+ if os.environ.get("RUNTIME_LABS_BROKER", "local") == "pubsub":
325
+ _default_broker = PubSubBroker()
326
+ else:
327
+ _default_broker = LocalBroker(local_data_dir() / "broker.db")
328
+ return _default_broker
329
+
330
+
331
+ def publish_scan_completed(broker: Broker, event: ScanCompleted) -> None:
332
+ # A topic drops messages that arrive before any subscription exists, so the
333
+ # publisher provisions the pipeline's subscription and no scan is lost while
334
+ # the pipeline is down.
335
+ broker.ensure(SCAN_COMPLETED_TOPIC, PIPELINE_SUBSCRIPTION)
336
+ broker.publish(SCAN_COMPLETED_TOPIC, event.model_dump_json().encode())
337
+
338
+
339
+ def publish_spans_ingested(broker: Broker, event: SpansIngested) -> None:
340
+ broker.ensure(SPANS_INGESTED_TOPIC, CORRELATION_SUBSCRIPTION)
341
+ broker.publish(SPANS_INGESTED_TOPIC, event.model_dump_json().encode())
@@ -0,0 +1,63 @@
1
+ """Canonical Git repository identities shared by onboarding and SDK bootstrap."""
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+ from urllib.parse import urlsplit
7
+
8
+
9
+ def normalize_git_url(value: str) -> str:
10
+ value = value.strip()
11
+ if not value or any(c.isspace() for c in value):
12
+ raise ValueError("Invalid Git remote")
13
+ canonical_port = False
14
+ if "://" not in value and re.match(r"^[^/@:]+:[0-9]+/", value):
15
+ value = "ssh://" + value
16
+ canonical_port = True
17
+ if "://" not in value:
18
+ scp = re.fullmatch(r"(?:[^/@:]+@)?([^/:]+):(.+)", value)
19
+ value = f"ssh://{scp[1]}/{scp[2]}" if scp else "https://" + value
20
+ parsed = urlsplit(value)
21
+ if parsed.scheme not in {"https", "http", "ssh", "git"} or not parsed.hostname:
22
+ raise ValueError("Unsupported Git remote")
23
+ path = parsed.path.strip("/").removesuffix(".git")
24
+ parts = path.split("/")
25
+ if len(parts) < 2 or any(p in {"", ".", ".."} for p in parts):
26
+ raise ValueError("Git remote must include owner and repository")
27
+ host = parsed.hostname.lower()
28
+ if host == "github.com":
29
+ path = path.lower()
30
+ port = parsed.port
31
+ if port and (
32
+ canonical_port
33
+ or (parsed.scheme, port) not in {("ssh", 22), ("http", 80), ("https", 443), ("git", 9418)}
34
+ ):
35
+ host += f":{port}"
36
+ return f"{host}/{path}"
37
+
38
+
39
+ def _git(directory, *args):
40
+ return subprocess.run(
41
+ ["git", "-C", str(directory), *args],
42
+ check=True,
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=3,
46
+ ).stdout.strip()
47
+
48
+
49
+ def git_identity(directory: Path) -> tuple[str, str | None]:
50
+ def git(*args):
51
+ return _git(directory, *args)
52
+
53
+ remote = normalize_git_url(git("config", "--get", "remote.origin.url"))
54
+ try:
55
+ commit = git("rev-parse", "HEAD")
56
+ except subprocess.SubprocessError:
57
+ commit = None
58
+ return remote, commit
59
+
60
+
61
+ def git_subdirectory(directory: Path) -> str:
62
+ """Where ``directory`` sits inside its checkout: "" at the root, else "a/b"."""
63
+ return _git(directory, "rev-parse", "--show-prefix").strip("/")