controlloop 0.1.0__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.
- controlloop/__init__.py +16 -0
- controlloop/__main__.py +6 -0
- controlloop/acbom/__init__.py +93 -0
- controlloop/acbom/completeness.py +117 -0
- controlloop/acbom/identity.py +210 -0
- controlloop/acbom/models.py +395 -0
- controlloop/acbom/serialization.py +374 -0
- controlloop/acbom/versioning.py +104 -0
- controlloop/adapters/__init__.py +53 -0
- controlloop/adapters/a2a.py +479 -0
- controlloop/adapters/crewai.py +646 -0
- controlloop/adapters/framework.py +74 -0
- controlloop/adapters/framework_versions.py +211 -0
- controlloop/adapters/iam.py +485 -0
- controlloop/adapters/langgraph.py +749 -0
- controlloop/adapters/mcp.py +179 -0
- controlloop/adapters/mcp_live.py +582 -0
- controlloop/adapters/oasf.py +183 -0
- controlloop/adapters/openai_agents.py +824 -0
- controlloop/adapters/openapi.py +377 -0
- controlloop/adapters/runner.py +116 -0
- controlloop/adapters/sandbox.py +66 -0
- controlloop/baseline/__init__.py +102 -0
- controlloop/baseline/integrity.py +124 -0
- controlloop/baseline/merge_base.py +602 -0
- controlloop/baseline/models.py +206 -0
- controlloop/baseline/provenance.py +162 -0
- controlloop/baseline/resolution.py +163 -0
- controlloop/baseline/serialization.py +115 -0
- controlloop/baseline/staleness.py +227 -0
- controlloop/bom.py +226 -0
- controlloop/bounded_recursion.py +50 -0
- controlloop/capability/__init__.py +48 -0
- controlloop/capability/blast_radius.py +289 -0
- controlloop/capability/compute.py +479 -0
- controlloop/capability/models.py +129 -0
- controlloop/capability/taxonomy.py +158 -0
- controlloop/cli.py +2368 -0
- controlloop/config.py +517 -0
- controlloop/diagnostics.py +149 -0
- controlloop/diff/__init__.py +87 -0
- controlloop/diff/compute.py +298 -0
- controlloop/diff/material_summary.py +308 -0
- controlloop/diff/models.py +156 -0
- controlloop/diff/reduction.py +118 -0
- controlloop/diff/report.py +116 -0
- controlloop/exit_codes.py +140 -0
- controlloop/findings/__init__.py +58 -0
- controlloop/findings/identity.py +148 -0
- controlloop/findings/models.py +344 -0
- controlloop/findings/rendering.py +152 -0
- controlloop/ignore.py +178 -0
- controlloop/manifest/__init__.py +59 -0
- controlloop/manifest/annotation.py +161 -0
- controlloop/manifest/approvals.py +92 -0
- controlloop/manifest/ceiling.py +441 -0
- controlloop/manifest/draft.py +467 -0
- controlloop/manifest/models.py +384 -0
- controlloop/manifest/parser.py +100 -0
- controlloop/manifest/reconciliation.py +148 -0
- controlloop/output.py +157 -0
- controlloop/policy/__init__.py +126 -0
- controlloop/policy/engine.py +279 -0
- controlloop/policy/models.py +181 -0
- controlloop/policy/rulepack.py +274 -0
- controlloop/policy/rules/__init__.py +262 -0
- controlloop/policy/rules/_shared.py +86 -0
- controlloop/policy/rules/broad_permission.py +270 -0
- controlloop/policy/rules/capability_ceiling.py +522 -0
- controlloop/policy/rules/destructive_or_financial_action.py +296 -0
- controlloop/policy/rules/exfiltration_triangle.py +537 -0
- controlloop/policy/rules/hardcoded_secrets.py +194 -0
- controlloop/policy/rules/missing_action_logging.py +184 -0
- controlloop/policy/rules/sensitive_write_without_approval.py +222 -0
- controlloop/policy/rules/unauthorized_delegation.py +241 -0
- controlloop/policy/rules/undeclared_tools.py +238 -0
- controlloop/policy/rules/unresolved_dynamic_registration.py +199 -0
- controlloop/policy/suppressions.py +260 -0
- controlloop/policy/thresholds.py +369 -0
- controlloop/py.typed +0 -0
- controlloop/redaction.py +327 -0
- controlloop/reporting/__init__.py +84 -0
- controlloop/reporting/cyclonedx.py +116 -0
- controlloop/reporting/evidence.py +170 -0
- controlloop/reporting/report.py +141 -0
- controlloop/reporting/sarif.py +119 -0
- controlloop/reporting/terminal.py +286 -0
- controlloop/repository.py +786 -0
- controlloop/schemas/acbom/1.0.0/acbom.schema.json +472 -0
- controlloop/schemas/acbom/1.1.0/acbom.schema.json +481 -0
- controlloop/schemas/baseline/1.0.0/baseline.schema.json +243 -0
- controlloop/schemas/baseline/1.1.0/baseline.schema.json +423 -0
- controlloop/schemas/evidence/1.0.0/evidence.schema.json +805 -0
- controlloop/schemas/evidence/1.1.0/evidence.schema.json +833 -0
- controlloop/schemas/evidence/1.2.0/evidence.schema.json +886 -0
- controlloop/schemas/manifest/1.0.0/manifest.schema.json +408 -0
- controlloop/schemas/manifest/1.1.0/manifest.schema.json +568 -0
- controlloop/schemas/manifest/1.2.0/manifest.schema.json +568 -0
- controlloop/schemas/report/1.0.0/report.schema.json +784 -0
- controlloop/schemas/report/1.1.0/report.schema.json +837 -0
- controlloop/secret_scan.py +452 -0
- controlloop/verbosity.py +201 -0
- controlloop-0.1.0.dist-info/METADATA +427 -0
- controlloop-0.1.0.dist-info/RECORD +107 -0
- controlloop-0.1.0.dist-info/WHEEL +4 -0
- controlloop-0.1.0.dist-info/entry_points.txt +2 -0
- controlloop-0.1.0.dist-info/licenses/LICENSE +191 -0
controlloop/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""ControlLoop Community CLI."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _distribution_version() -> str:
|
|
7
|
+
"""Return the installed version or a safe source-tree fallback."""
|
|
8
|
+
try:
|
|
9
|
+
return version("controlloop")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
return "0.0.0+uninstalled"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__version__ = _distribution_version()
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__"]
|
controlloop/__main__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Canonical Agent Capability Bill of Materials models."""
|
|
2
|
+
|
|
3
|
+
from controlloop.acbom.completeness import (
|
|
4
|
+
INCOMPLETE_DIAGNOSTIC_REGISTRY,
|
|
5
|
+
completeness_summary,
|
|
6
|
+
incomplete_diagnostic,
|
|
7
|
+
)
|
|
8
|
+
from controlloop.acbom.identity import (
|
|
9
|
+
IDENTITY_VERSION,
|
|
10
|
+
EdgeIdentityMaterial,
|
|
11
|
+
NodeIdentityMaterial,
|
|
12
|
+
SemanticIdentity,
|
|
13
|
+
edge_id,
|
|
14
|
+
node_id,
|
|
15
|
+
)
|
|
16
|
+
from controlloop.acbom.models import (
|
|
17
|
+
ACBOMDocument,
|
|
18
|
+
Confidence,
|
|
19
|
+
Diagnostic,
|
|
20
|
+
DiagnosticSeverity,
|
|
21
|
+
Edge,
|
|
22
|
+
EdgeStatus,
|
|
23
|
+
EdgeType,
|
|
24
|
+
Evidence,
|
|
25
|
+
Generator,
|
|
26
|
+
KnownBoolean,
|
|
27
|
+
Node,
|
|
28
|
+
NodeType,
|
|
29
|
+
Repository,
|
|
30
|
+
SecurityAttributes,
|
|
31
|
+
Sensitivity,
|
|
32
|
+
StandardIdentifier,
|
|
33
|
+
)
|
|
34
|
+
from controlloop.acbom.serialization import (
|
|
35
|
+
CanonicalizationError,
|
|
36
|
+
EdgeMergeConflictError,
|
|
37
|
+
NodeMergeConflictError,
|
|
38
|
+
canonical_json_bytes,
|
|
39
|
+
canonicalize_document,
|
|
40
|
+
compute_digest,
|
|
41
|
+
merge_duplicate_edges,
|
|
42
|
+
merge_duplicate_nodes,
|
|
43
|
+
serialize_document,
|
|
44
|
+
with_digest,
|
|
45
|
+
)
|
|
46
|
+
from controlloop.acbom.versioning import (
|
|
47
|
+
CURRENT_SCHEMA_VERSION,
|
|
48
|
+
IncompatibleSchemaVersionError,
|
|
49
|
+
check_schema_version_compatible,
|
|
50
|
+
load_acbom_document,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"CURRENT_SCHEMA_VERSION",
|
|
55
|
+
"IDENTITY_VERSION",
|
|
56
|
+
"INCOMPLETE_DIAGNOSTIC_REGISTRY",
|
|
57
|
+
"ACBOMDocument",
|
|
58
|
+
"CanonicalizationError",
|
|
59
|
+
"Confidence",
|
|
60
|
+
"Diagnostic",
|
|
61
|
+
"DiagnosticSeverity",
|
|
62
|
+
"Edge",
|
|
63
|
+
"EdgeIdentityMaterial",
|
|
64
|
+
"EdgeMergeConflictError",
|
|
65
|
+
"EdgeStatus",
|
|
66
|
+
"EdgeType",
|
|
67
|
+
"Evidence",
|
|
68
|
+
"Generator",
|
|
69
|
+
"IncompatibleSchemaVersionError",
|
|
70
|
+
"KnownBoolean",
|
|
71
|
+
"Node",
|
|
72
|
+
"NodeIdentityMaterial",
|
|
73
|
+
"NodeMergeConflictError",
|
|
74
|
+
"NodeType",
|
|
75
|
+
"Repository",
|
|
76
|
+
"SecurityAttributes",
|
|
77
|
+
"SemanticIdentity",
|
|
78
|
+
"Sensitivity",
|
|
79
|
+
"StandardIdentifier",
|
|
80
|
+
"canonical_json_bytes",
|
|
81
|
+
"canonicalize_document",
|
|
82
|
+
"check_schema_version_compatible",
|
|
83
|
+
"completeness_summary",
|
|
84
|
+
"compute_digest",
|
|
85
|
+
"edge_id",
|
|
86
|
+
"incomplete_diagnostic",
|
|
87
|
+
"load_acbom_document",
|
|
88
|
+
"merge_duplicate_edges",
|
|
89
|
+
"merge_duplicate_nodes",
|
|
90
|
+
"node_id",
|
|
91
|
+
"serialize_document",
|
|
92
|
+
"with_digest",
|
|
93
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Unknown and incomplete analysis representation for canonical ACBOMs.
|
|
2
|
+
|
|
3
|
+
Dynamic or unsupported behavior is represented as an explicit unknown --
|
|
4
|
+
never omitted silently, since a silently dropped capability produces a
|
|
5
|
+
confident pass, the worst failure this product can have. This module
|
|
6
|
+
mirrors `controlloop.repository`'s `DIAGNOSTIC_REGISTRY`/`summarize_skips`
|
|
7
|
+
pattern at the canonical ACBOM layer: a closed, documented code registry,
|
|
8
|
+
a constructor enforcing the shape, and a deterministic summary view.
|
|
9
|
+
|
|
10
|
+
No new schema surface is introduced. `Diagnostic.incomplete` and
|
|
11
|
+
`Diagnostic.confidence` already exist on the canonical model; a
|
|
12
|
+
completeness summary is a filtered, sorted view over an
|
|
13
|
+
`ACBOMDocument`'s existing `diagnostics`, not new stored state.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
from controlloop.acbom.models import (
|
|
21
|
+
ACBOMDocument,
|
|
22
|
+
Confidence,
|
|
23
|
+
Diagnostic,
|
|
24
|
+
DiagnosticCode,
|
|
25
|
+
DiagnosticSeverity,
|
|
26
|
+
EvidenceCollection,
|
|
27
|
+
SafeText,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_UNRESOLVED_REFERENCE_CODE = "INCOMPLETE-UNRESOLVED-REFERENCE"
|
|
31
|
+
_DYNAMIC_REGISTRATION_CODE = "INCOMPLETE-DYNAMIC-REGISTRATION"
|
|
32
|
+
_UNPARSED_REGION_CODE = "INCOMPLETE-UNPARSED-REGION"
|
|
33
|
+
_BOUND_EXCEEDED_CODE = "INCOMPLETE-BOUND-EXCEEDED"
|
|
34
|
+
_ADAPTER_FAILURE_CODE = "INCOMPLETE-ADAPTER-FAILURE"
|
|
35
|
+
_UNVERIFIED_DECLARATION_CODE = "INCOMPLETE-UNVERIFIED-DECLARATION"
|
|
36
|
+
_CYCLE_DETECTED_CODE = "INCOMPLETE-CYCLE-DETECTED"
|
|
37
|
+
|
|
38
|
+
INCOMPLETE_DIAGNOSTIC_REGISTRY: Mapping[str, str] = {
|
|
39
|
+
_UNRESOLVED_REFERENCE_CODE: (
|
|
40
|
+
"A reference to a capability, tool, or identity could not be "
|
|
41
|
+
"resolved to a concrete node."
|
|
42
|
+
),
|
|
43
|
+
_DYNAMIC_REGISTRATION_CODE: (
|
|
44
|
+
"A tool, agent, or capability is registered at runtime and "
|
|
45
|
+
"cannot be proven statically."
|
|
46
|
+
),
|
|
47
|
+
_UNPARSED_REGION_CODE: (
|
|
48
|
+
"A region of an otherwise-supported input could not be parsed."
|
|
49
|
+
),
|
|
50
|
+
_BOUND_EXCEEDED_CODE: (
|
|
51
|
+
"Analysis stopped because a configured resource or depth bound "
|
|
52
|
+
"was reached before discovery completed."
|
|
53
|
+
),
|
|
54
|
+
_ADAPTER_FAILURE_CODE: (
|
|
55
|
+
"An adapter raised while detecting or discovering evidence, and "
|
|
56
|
+
"was isolated so the rest of the scan could continue."
|
|
57
|
+
),
|
|
58
|
+
_UNVERIFIED_DECLARATION_CODE: (
|
|
59
|
+
"A human-authored manifest declaration names a capability that "
|
|
60
|
+
"discovered evidence could not corroborate."
|
|
61
|
+
),
|
|
62
|
+
_CYCLE_DETECTED_CODE: (
|
|
63
|
+
"A graph traversal reached a node already present earlier on the "
|
|
64
|
+
"same path and stopped there, rather than looping or reporting "
|
|
65
|
+
"an arbitrarily long path through the cycle."
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def incomplete_diagnostic(
|
|
71
|
+
*,
|
|
72
|
+
code: DiagnosticCode,
|
|
73
|
+
message: SafeText,
|
|
74
|
+
evidence: EvidenceCollection,
|
|
75
|
+
) -> Diagnostic:
|
|
76
|
+
"""Return a `Diagnostic` representing an explicit unknown (REQ-1).
|
|
77
|
+
|
|
78
|
+
`code` must be a member of `INCOMPLETE_DIAGNOSTIC_REGISTRY`. Always
|
|
79
|
+
`incomplete=True` and `confidence=Confidence.UNKNOWN` -- an
|
|
80
|
+
incompleteness diagnostic can never claim confident knowledge of the
|
|
81
|
+
very thing it reports as unresolved.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
if code not in INCOMPLETE_DIAGNOSTIC_REGISTRY:
|
|
85
|
+
raise ValueError(f"{code!r} is not a registered incompleteness code")
|
|
86
|
+
|
|
87
|
+
return Diagnostic(
|
|
88
|
+
code=code,
|
|
89
|
+
severity=DiagnosticSeverity.WARNING,
|
|
90
|
+
message=message,
|
|
91
|
+
confidence=Confidence.UNKNOWN,
|
|
92
|
+
incomplete=True,
|
|
93
|
+
evidence=evidence,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def completeness_summary(document: ACBOMDocument) -> tuple[Diagnostic, ...]:
|
|
98
|
+
"""Return every reason `document`'s analysis was incomplete (REQ-2).
|
|
99
|
+
|
|
100
|
+
A deterministic, sorted view over `document.diagnostics` filtered to
|
|
101
|
+
`incomplete=True` -- independent of the input collection's order, the
|
|
102
|
+
same guarantee `repository.summarize_skips` already keeps for
|
|
103
|
+
repository-traversal diagnostics.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
incomplete = [
|
|
107
|
+
diagnostic for diagnostic in document.diagnostics if diagnostic.incomplete
|
|
108
|
+
]
|
|
109
|
+
return tuple(
|
|
110
|
+
sorted(
|
|
111
|
+
incomplete,
|
|
112
|
+
key=lambda diagnostic: (
|
|
113
|
+
diagnostic.evidence[0].path or "",
|
|
114
|
+
diagnostic.code,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Stable, privacy-preserving identifiers for canonical ACBOM components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from typing import Annotated, Any, ClassVar, Self
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, StringConstraints, model_validator
|
|
12
|
+
|
|
13
|
+
from controlloop.acbom.models import (
|
|
14
|
+
EdgeId,
|
|
15
|
+
EdgeType,
|
|
16
|
+
NodeId,
|
|
17
|
+
NodeType,
|
|
18
|
+
RelativePath,
|
|
19
|
+
SafeIdentifier,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
IDENTITY_VERSION = 1
|
|
23
|
+
_EDGE_NAMESPACE = "controlloop.edge"
|
|
24
|
+
|
|
25
|
+
IdentityString = Annotated[
|
|
26
|
+
str,
|
|
27
|
+
StringConstraints(
|
|
28
|
+
strict=True,
|
|
29
|
+
min_length=1,
|
|
30
|
+
max_length=512,
|
|
31
|
+
pattern=r"^[^\x00-\x1f\x7f]*$",
|
|
32
|
+
),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_PROHIBITED_FIELD_CATEGORIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
36
|
+
("private key", ("privatekey",)),
|
|
37
|
+
(
|
|
38
|
+
"environment value",
|
|
39
|
+
(
|
|
40
|
+
"environmentvalue",
|
|
41
|
+
"environmentvariablevalue",
|
|
42
|
+
"envvalue",
|
|
43
|
+
"envvarvalue",
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
("source content", ("sourcecontent", "sourcecode", "rawsource")),
|
|
47
|
+
("credential", ("credential",)),
|
|
48
|
+
("password", ("password", "passwd")),
|
|
49
|
+
("secret", ("secret",)),
|
|
50
|
+
("token", ("token",)),
|
|
51
|
+
)
|
|
52
|
+
_EXCLUDED_FIELD_CATEGORIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
53
|
+
("presentation", ("display", "label", "description")),
|
|
54
|
+
("evidence", ("evidence", "confidence", "linenumber", "line")),
|
|
55
|
+
("timestamp", ("timestamp", "createdat", "updatedat")),
|
|
56
|
+
("version", ("schemaversion", "generatorversion")),
|
|
57
|
+
("security attribute", ("securityattribute",)),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalized_field_name(field_name: str) -> str:
|
|
62
|
+
return re.sub(r"[^a-z0-9]", "", field_name.casefold())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _field_category(field_name: object) -> tuple[str, str] | None:
|
|
66
|
+
if not isinstance(field_name, str):
|
|
67
|
+
return ("unsupported", "unsupported")
|
|
68
|
+
|
|
69
|
+
normalized = _normalized_field_name(field_name)
|
|
70
|
+
for category, markers in _PROHIBITED_FIELD_CATEGORIES:
|
|
71
|
+
if any(marker in normalized for marker in markers):
|
|
72
|
+
return ("prohibited", category)
|
|
73
|
+
for category, markers in _EXCLUDED_FIELD_CATEGORIES:
|
|
74
|
+
if any(marker in normalized for marker in markers):
|
|
75
|
+
return ("excluded", category)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _IdentityModel(BaseModel):
|
|
80
|
+
model_config = ConfigDict(
|
|
81
|
+
extra="forbid",
|
|
82
|
+
frozen=True,
|
|
83
|
+
hide_input_in_errors=True,
|
|
84
|
+
strict=True,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_allowed_fields: ClassVar[frozenset[str]]
|
|
88
|
+
|
|
89
|
+
@model_validator(mode="before")
|
|
90
|
+
@classmethod
|
|
91
|
+
def reject_unapproved_fields(cls, value: Any) -> Any:
|
|
92
|
+
if not isinstance(value, Mapping):
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
for field_name in value:
|
|
96
|
+
if field_name in cls._allowed_fields:
|
|
97
|
+
continue
|
|
98
|
+
category = _field_category(field_name)
|
|
99
|
+
if category is None:
|
|
100
|
+
raise ValueError("unsupported identity field category")
|
|
101
|
+
disposition, safe_category = category
|
|
102
|
+
raise ValueError(f"{disposition} identity field category: {safe_category}")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SemanticIdentity(_IdentityModel):
|
|
107
|
+
"""Allowlisted semantic identity for the canonical ACBOM contract."""
|
|
108
|
+
|
|
109
|
+
_allowed_fields = frozenset({"identifier", "source_path", "in_document_key"})
|
|
110
|
+
|
|
111
|
+
identifier: IdentityString | None = None
|
|
112
|
+
source_path: RelativePath | None = None
|
|
113
|
+
in_document_key: IdentityString | None = None
|
|
114
|
+
|
|
115
|
+
@model_validator(mode="after")
|
|
116
|
+
def validate_identity_shape(self) -> Self:
|
|
117
|
+
uses_identifier = self.identifier is not None
|
|
118
|
+
uses_adapter_fallback = (
|
|
119
|
+
self.source_path is not None and self.in_document_key is not None
|
|
120
|
+
)
|
|
121
|
+
if uses_identifier == uses_adapter_fallback:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"semantic identity requires exactly one approved identity category"
|
|
124
|
+
)
|
|
125
|
+
if (self.source_path is None) != (self.in_document_key is None):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"adapter fallback identity requires path and in-document key"
|
|
128
|
+
)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class NodeIdentityMaterial(_IdentityModel):
|
|
133
|
+
"""Typed node identity material accepted by the hashing boundary."""
|
|
134
|
+
|
|
135
|
+
_allowed_fields = frozenset({"node_type", "namespace", "semantic_identity"})
|
|
136
|
+
|
|
137
|
+
node_type: NodeType
|
|
138
|
+
namespace: SafeIdentifier
|
|
139
|
+
semantic_identity: SemanticIdentity
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class EdgeIdentityMaterial(_IdentityModel):
|
|
143
|
+
"""Typed edge identity material accepted by the hashing boundary."""
|
|
144
|
+
|
|
145
|
+
_allowed_fields = frozenset(
|
|
146
|
+
{
|
|
147
|
+
"relationship_type",
|
|
148
|
+
"source_id",
|
|
149
|
+
"target_id",
|
|
150
|
+
"relationship_discriminator",
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
relationship_type: EdgeType
|
|
155
|
+
source_id: NodeId
|
|
156
|
+
target_id: NodeId
|
|
157
|
+
relationship_discriminator: IdentityString
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _canonical_identity_bytes(
|
|
161
|
+
*,
|
|
162
|
+
node_type: str,
|
|
163
|
+
namespace: str,
|
|
164
|
+
semantic_identity: dict[str, object],
|
|
165
|
+
) -> bytes:
|
|
166
|
+
payload = {
|
|
167
|
+
"identity_version": IDENTITY_VERSION,
|
|
168
|
+
"namespace": namespace,
|
|
169
|
+
"node_type": node_type,
|
|
170
|
+
"semantic_identity": semantic_identity,
|
|
171
|
+
}
|
|
172
|
+
return json.dumps(
|
|
173
|
+
payload,
|
|
174
|
+
ensure_ascii=False,
|
|
175
|
+
allow_nan=False,
|
|
176
|
+
sort_keys=True,
|
|
177
|
+
separators=(",", ":"),
|
|
178
|
+
).encode("utf-8")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def node_id(material: NodeIdentityMaterial) -> NodeId:
|
|
182
|
+
"""Return the stable canonical ID for validated node identity material."""
|
|
183
|
+
|
|
184
|
+
canonical_bytes = _canonical_identity_bytes(
|
|
185
|
+
node_type=material.node_type.value,
|
|
186
|
+
namespace=material.namespace,
|
|
187
|
+
semantic_identity=material.semantic_identity.model_dump(
|
|
188
|
+
mode="json",
|
|
189
|
+
exclude_none=True,
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
digest = hashlib.sha256(canonical_bytes).hexdigest()
|
|
193
|
+
return f"clp:{material.node_type.value}:sha256:{digest}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def edge_id(material: EdgeIdentityMaterial) -> EdgeId:
|
|
197
|
+
"""Return the stable canonical ID for validated edge identity material."""
|
|
198
|
+
|
|
199
|
+
canonical_bytes = _canonical_identity_bytes(
|
|
200
|
+
node_type="edge",
|
|
201
|
+
namespace=_EDGE_NAMESPACE,
|
|
202
|
+
semantic_identity={
|
|
203
|
+
"relationship_discriminator": material.relationship_discriminator,
|
|
204
|
+
"relationship_type": material.relationship_type.value,
|
|
205
|
+
"source_id": material.source_id,
|
|
206
|
+
"target_id": material.target_id,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
digest = hashlib.sha256(canonical_bytes).hexdigest()
|
|
210
|
+
return f"clp:edge:sha256:{digest}"
|