shellbrain 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.
Files changed (165) hide show
  1. app/__init__.py +1 -0
  2. app/__main__.py +7 -0
  3. app/boot/__init__.py +1 -0
  4. app/boot/admin_db.py +88 -0
  5. app/boot/config.py +14 -0
  6. app/boot/create_policy.py +52 -0
  7. app/boot/db.py +70 -0
  8. app/boot/embeddings.py +55 -0
  9. app/boot/home.py +45 -0
  10. app/boot/migrations.py +61 -0
  11. app/boot/read_policy.py +179 -0
  12. app/boot/repos.py +15 -0
  13. app/boot/retrieval.py +3 -0
  14. app/boot/thresholds.py +19 -0
  15. app/boot/update_policy.py +34 -0
  16. app/boot/use_cases.py +22 -0
  17. app/config/__init__.py +1 -0
  18. app/config/defaults/create_policy.yaml +7 -0
  19. app/config/defaults/read_policy.yaml +25 -0
  20. app/config/defaults/runtime.yaml +10 -0
  21. app/config/defaults/thresholds.yaml +3 -0
  22. app/config/defaults/update_policy.yaml +5 -0
  23. app/config/loader.py +58 -0
  24. app/core/__init__.py +1 -0
  25. app/core/contracts/__init__.py +1 -0
  26. app/core/contracts/errors.py +29 -0
  27. app/core/contracts/requests.py +211 -0
  28. app/core/contracts/responses.py +15 -0
  29. app/core/entities/__init__.py +1 -0
  30. app/core/entities/associations.py +58 -0
  31. app/core/entities/episodes.py +66 -0
  32. app/core/entities/evidence.py +29 -0
  33. app/core/entities/facts.py +30 -0
  34. app/core/entities/guidance.py +47 -0
  35. app/core/entities/identity.py +48 -0
  36. app/core/entities/memory.py +34 -0
  37. app/core/entities/runtime_context.py +19 -0
  38. app/core/entities/session_state.py +31 -0
  39. app/core/entities/telemetry.py +152 -0
  40. app/core/entities/utility.py +14 -0
  41. app/core/interfaces/__init__.py +1 -0
  42. app/core/interfaces/clock.py +12 -0
  43. app/core/interfaces/config.py +28 -0
  44. app/core/interfaces/embeddings.py +12 -0
  45. app/core/interfaces/idgen.py +11 -0
  46. app/core/interfaces/repos.py +279 -0
  47. app/core/interfaces/retrieval.py +20 -0
  48. app/core/interfaces/session_state_store.py +33 -0
  49. app/core/interfaces/unit_of_work.py +50 -0
  50. app/core/policies/__init__.py +1 -0
  51. app/core/policies/_shared/__init__.py +1 -0
  52. app/core/policies/_shared/executor.py +132 -0
  53. app/core/policies/_shared/side_effects.py +9 -0
  54. app/core/policies/create_policy/__init__.py +1 -0
  55. app/core/policies/create_policy/pipeline.py +96 -0
  56. app/core/policies/read_policy/__init__.py +1 -0
  57. app/core/policies/read_policy/bm25.py +114 -0
  58. app/core/policies/read_policy/context_pack_builder.py +140 -0
  59. app/core/policies/read_policy/expansion.py +132 -0
  60. app/core/policies/read_policy/fusion_rrf.py +34 -0
  61. app/core/policies/read_policy/lexical_query.py +101 -0
  62. app/core/policies/read_policy/pipeline.py +93 -0
  63. app/core/policies/read_policy/scenario_lift.py +11 -0
  64. app/core/policies/read_policy/scoring.py +61 -0
  65. app/core/policies/read_policy/seed_retrieval.py +54 -0
  66. app/core/policies/read_policy/utility_prior.py +11 -0
  67. app/core/policies/update_policy/__init__.py +1 -0
  68. app/core/policies/update_policy/pipeline.py +80 -0
  69. app/core/use_cases/__init__.py +1 -0
  70. app/core/use_cases/build_guidance.py +85 -0
  71. app/core/use_cases/create_memory.py +26 -0
  72. app/core/use_cases/manage_session_state.py +159 -0
  73. app/core/use_cases/read_memory.py +21 -0
  74. app/core/use_cases/record_episode_sync_telemetry.py +19 -0
  75. app/core/use_cases/record_operation_telemetry.py +32 -0
  76. app/core/use_cases/sync_episode.py +162 -0
  77. app/core/use_cases/update_memory.py +40 -0
  78. app/migrations/__init__.py +1 -0
  79. app/migrations/env.py +65 -0
  80. app/migrations/versions/20260226_0001_initial_schema.py +232 -0
  81. app/migrations/versions/20260312_0002_add_hard_invariants.py +60 -0
  82. app/migrations/versions/20260312_0003_drop_create_confidence.py +40 -0
  83. app/migrations/versions/20260313_0004_episode_sync_hardening.py +71 -0
  84. app/migrations/versions/20260313_0005_evidence_episode_event_refs.py +45 -0
  85. app/migrations/versions/20260318_0006_usage_telemetry_schema.py +175 -0
  86. app/migrations/versions/20260319_0007_identity_session_guidance.py +49 -0
  87. app/migrations/versions/20260320_0008_instance_metadata_and_backup_safety.py +31 -0
  88. app/migrations/versions/__init__.py +1 -0
  89. app/periphery/__init__.py +1 -0
  90. app/periphery/admin/__init__.py +1 -0
  91. app/periphery/admin/backup.py +360 -0
  92. app/periphery/admin/destructive_guard.py +32 -0
  93. app/periphery/admin/doctor.py +192 -0
  94. app/periphery/admin/init.py +996 -0
  95. app/periphery/admin/instance_guard.py +211 -0
  96. app/periphery/admin/machine_state.py +354 -0
  97. app/periphery/admin/privileges.py +42 -0
  98. app/periphery/admin/repo_state.py +266 -0
  99. app/periphery/admin/restore.py +30 -0
  100. app/periphery/cli/__init__.py +1 -0
  101. app/periphery/cli/handlers.py +830 -0
  102. app/periphery/cli/hydration.py +119 -0
  103. app/periphery/cli/main.py +710 -0
  104. app/periphery/cli/presenter_json.py +10 -0
  105. app/periphery/cli/schema_validation.py +201 -0
  106. app/periphery/db/__init__.py +1 -0
  107. app/periphery/db/engine.py +10 -0
  108. app/periphery/db/models/__init__.py +1 -0
  109. app/periphery/db/models/associations.py +55 -0
  110. app/periphery/db/models/episodes.py +55 -0
  111. app/periphery/db/models/evidence.py +19 -0
  112. app/periphery/db/models/experiences.py +33 -0
  113. app/periphery/db/models/instance_metadata.py +17 -0
  114. app/periphery/db/models/memories.py +39 -0
  115. app/periphery/db/models/metadata.py +6 -0
  116. app/periphery/db/models/registry.py +18 -0
  117. app/periphery/db/models/telemetry.py +174 -0
  118. app/periphery/db/models/utility.py +19 -0
  119. app/periphery/db/models/views.py +154 -0
  120. app/periphery/db/repos/__init__.py +1 -0
  121. app/periphery/db/repos/relational/__init__.py +1 -0
  122. app/periphery/db/repos/relational/associations_repo.py +117 -0
  123. app/periphery/db/repos/relational/episodes_repo.py +188 -0
  124. app/periphery/db/repos/relational/evidence_repo.py +82 -0
  125. app/periphery/db/repos/relational/experiences_repo.py +41 -0
  126. app/periphery/db/repos/relational/memories_repo.py +99 -0
  127. app/periphery/db/repos/relational/read_policy_repo.py +202 -0
  128. app/periphery/db/repos/relational/telemetry_repo.py +161 -0
  129. app/periphery/db/repos/relational/utility_repo.py +30 -0
  130. app/periphery/db/repos/semantic/__init__.py +1 -0
  131. app/periphery/db/repos/semantic/keyword_retrieval_repo.py +63 -0
  132. app/periphery/db/repos/semantic/semantic_retrieval_repo.py +111 -0
  133. app/periphery/db/session.py +10 -0
  134. app/periphery/db/uow.py +75 -0
  135. app/periphery/embeddings/__init__.py +1 -0
  136. app/periphery/embeddings/local_provider.py +35 -0
  137. app/periphery/embeddings/query_vector_search.py +18 -0
  138. app/periphery/episodes/__init__.py +1 -0
  139. app/periphery/episodes/claude_code.py +387 -0
  140. app/periphery/episodes/codex.py +423 -0
  141. app/periphery/episodes/launcher.py +66 -0
  142. app/periphery/episodes/normalization.py +31 -0
  143. app/periphery/episodes/poller.py +299 -0
  144. app/periphery/episodes/source_discovery.py +66 -0
  145. app/periphery/episodes/tool_filter.py +165 -0
  146. app/periphery/identity/__init__.py +1 -0
  147. app/periphery/identity/claude_hook_install.py +67 -0
  148. app/periphery/identity/claude_runtime.py +83 -0
  149. app/periphery/identity/codex_runtime.py +32 -0
  150. app/periphery/identity/compatibility.py +38 -0
  151. app/periphery/identity/resolver.py +163 -0
  152. app/periphery/session_state/__init__.py +1 -0
  153. app/periphery/session_state/file_store.py +100 -0
  154. app/periphery/telemetry/__init__.py +33 -0
  155. app/periphery/telemetry/operation_summary.py +299 -0
  156. app/periphery/telemetry/session_selection.py +156 -0
  157. app/periphery/telemetry/sync_summary.py +65 -0
  158. app/periphery/validation/__init__.py +1 -0
  159. app/periphery/validation/integrity_validation.py +253 -0
  160. app/periphery/validation/semantic_validation.py +94 -0
  161. shellbrain-0.1.0.dist-info/METADATA +130 -0
  162. shellbrain-0.1.0.dist-info/RECORD +165 -0
  163. shellbrain-0.1.0.dist-info/WHEEL +5 -0
  164. shellbrain-0.1.0.dist-info/entry_points.txt +2 -0
  165. shellbrain-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,156 @@
1
+ """Helpers for resolving low-overhead telemetry session-selection summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from datetime import datetime, timezone
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from app.core.entities.telemetry import SessionSelectionSummary
12
+ from app.periphery.episodes.claude_code import list_claude_code_sessions_for_repo
13
+ from app.periphery.episodes.codex import list_codex_sessions_for_repo
14
+ from app.periphery.episodes.source_discovery import SUPPORTED_HOSTS, default_search_roots
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class EventsDiscoveryCandidate:
19
+ """Concrete host-session discovery result used by the events path."""
20
+
21
+ host_app: str
22
+ host_session_key: str
23
+ transcript_path: Path
24
+ search_roots: list[Path]
25
+ summary: SessionSelectionSummary
26
+
27
+
28
+ def discover_events_candidate(
29
+ *,
30
+ repo_root: Path,
31
+ search_roots_by_host: dict[str, list[Path]] | None = None,
32
+ ) -> EventsDiscoveryCandidate | None:
33
+ """Resolve the newest repo-matching host session across supported hosts."""
34
+
35
+ discovered: list[tuple[str, dict[str, Any], list[Path]]] = []
36
+ for host_app in SUPPORTED_HOSTS:
37
+ search_roots = _search_roots_for_host(
38
+ repo_root=repo_root,
39
+ host_app=host_app,
40
+ search_roots_by_host=search_roots_by_host,
41
+ )
42
+ candidates = _list_candidates_for_host(
43
+ host_app=host_app,
44
+ repo_root=repo_root,
45
+ search_roots=search_roots,
46
+ )
47
+ for candidate in candidates:
48
+ discovered.append((host_app, candidate, search_roots))
49
+
50
+ if not discovered:
51
+ return None
52
+
53
+ host_app, candidate, search_roots = max(
54
+ discovered,
55
+ key=lambda item: (float(item[1]["updated_at"]), item[0]),
56
+ )
57
+ host_session_key = str(candidate["host_session_key"])
58
+ summary = SessionSelectionSummary(
59
+ selected_host_app=host_app,
60
+ selected_host_session_key=host_session_key,
61
+ selected_thread_id=f"{host_app}:{host_session_key}",
62
+ matching_candidate_count=len(discovered),
63
+ selection_ambiguous=len(discovered) > 1,
64
+ )
65
+ return EventsDiscoveryCandidate(
66
+ host_app=host_app,
67
+ host_session_key=host_session_key,
68
+ transcript_path=Path(str(candidate["transcript_path"])),
69
+ search_roots=search_roots,
70
+ summary=summary,
71
+ )
72
+
73
+
74
+ def read_runtime_session_status(repo_root: Path) -> dict[str, Any] | None:
75
+ """Read the repo-local poller status file when it exists and is valid JSON."""
76
+
77
+ status_path = repo_root / ".shellbrain" / "episode_sync_status.json"
78
+ try:
79
+ payload = json.loads(status_path.read_text(encoding="utf-8"))
80
+ except (FileNotFoundError, json.JSONDecodeError):
81
+ return None
82
+ return payload if isinstance(payload, dict) else None
83
+
84
+
85
+ def summarize_runtime_selection(*, repo_root: Path, repo_id: str, uow=None) -> SessionSelectionSummary:
86
+ """Resolve lightweight session context from the repo-local poller status file."""
87
+
88
+ status = read_runtime_session_status(repo_root)
89
+ if status is None:
90
+ return SessionSelectionSummary()
91
+
92
+ hosts = status.get("hosts")
93
+ if not isinstance(hosts, dict):
94
+ return SessionSelectionSummary()
95
+
96
+ candidates: list[tuple[str, str, datetime]] = []
97
+ for host_app, raw_host_status in hosts.items():
98
+ if not isinstance(raw_host_status, dict):
99
+ continue
100
+ session_key = raw_host_status.get("current_session_key")
101
+ if not isinstance(session_key, str) or not session_key:
102
+ continue
103
+ candidates.append((host_app, session_key, _parse_status_time(raw_host_status.get("last_successful_sync_at"))))
104
+
105
+ if not candidates:
106
+ return SessionSelectionSummary()
107
+
108
+ host_app, session_key, _ = max(candidates, key=lambda item: (item[2], item[0]))
109
+ summary = SessionSelectionSummary(
110
+ selected_host_app=host_app,
111
+ selected_host_session_key=session_key,
112
+ selected_thread_id=f"{host_app}:{session_key}",
113
+ matching_candidate_count=len(candidates),
114
+ selection_ambiguous=len(candidates) > 1,
115
+ )
116
+ if uow is None:
117
+ return summary
118
+
119
+ episode = uow.episodes.get_episode_by_thread(repo_id=repo_id, thread_id=summary.selected_thread_id)
120
+ if episode is None:
121
+ return summary
122
+ return replace(summary, selected_episode_id=episode.id)
123
+
124
+
125
+ def _search_roots_for_host(
126
+ *,
127
+ repo_root: Path,
128
+ host_app: str,
129
+ search_roots_by_host: dict[str, list[Path]] | None,
130
+ ) -> list[Path]:
131
+ """Resolve bounded search roots for one host with optional test overrides."""
132
+
133
+ if search_roots_by_host is not None:
134
+ return [Path(path) for path in search_roots_by_host.get(host_app, [])]
135
+ return default_search_roots(repo_root=repo_root, host_app=host_app)
136
+
137
+
138
+ def _list_candidates_for_host(*, host_app: str, repo_root: Path, search_roots: list[Path]) -> list[dict[str, Any]]:
139
+ """List all repo-matching host sessions for one supported host."""
140
+
141
+ if host_app == "codex":
142
+ return list_codex_sessions_for_repo(repo_root=repo_root, search_roots=search_roots)
143
+ if host_app == "claude_code":
144
+ return list_claude_code_sessions_for_repo(repo_root=repo_root, search_roots=search_roots)
145
+ raise ValueError(f"Unsupported host app for telemetry discovery: {host_app}")
146
+
147
+
148
+ def _parse_status_time(value: object) -> datetime:
149
+ """Parse one poller status timestamp or return the minimum UTC instant."""
150
+
151
+ if not isinstance(value, str) or not value:
152
+ return datetime.min.replace(tzinfo=timezone.utc)
153
+ try:
154
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
155
+ except ValueError:
156
+ return datetime.min.replace(tzinfo=timezone.utc)
@@ -0,0 +1,65 @@
1
+ """Helpers for assembling episode-sync telemetry rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ from app.core.entities.telemetry import EpisodeSyncRunRecord, EpisodeSyncToolTypeRecord
8
+
9
+
10
+ def build_episode_sync_records(
11
+ *,
12
+ sync_run_id: str,
13
+ source: str,
14
+ invocation_id: str | None,
15
+ repo_id: str,
16
+ host_app: str,
17
+ host_session_key: str,
18
+ thread_id: str,
19
+ episode_id: str | None,
20
+ transcript_path: str | None,
21
+ outcome: str,
22
+ error_stage: str | None,
23
+ error_message: str | None,
24
+ duration_ms: int,
25
+ imported_event_count: int,
26
+ total_event_count: int,
27
+ user_event_count: int,
28
+ assistant_event_count: int,
29
+ tool_event_count: int,
30
+ system_event_count: int,
31
+ tool_type_counts: dict[str, int] | None,
32
+ ) -> tuple[EpisodeSyncRunRecord, list[EpisodeSyncToolTypeRecord]]:
33
+ """Build one sync-run record plus sorted per-tool aggregate rows."""
34
+
35
+ run = EpisodeSyncRunRecord(
36
+ id=sync_run_id,
37
+ source=source,
38
+ invocation_id=invocation_id,
39
+ repo_id=repo_id,
40
+ host_app=host_app,
41
+ host_session_key=host_session_key,
42
+ thread_id=thread_id,
43
+ episode_id=episode_id,
44
+ transcript_path=transcript_path,
45
+ outcome=outcome,
46
+ error_stage=error_stage,
47
+ error_message=error_message,
48
+ duration_ms=duration_ms,
49
+ imported_event_count=imported_event_count,
50
+ total_event_count=total_event_count,
51
+ user_event_count=user_event_count,
52
+ assistant_event_count=assistant_event_count,
53
+ tool_event_count=tool_event_count,
54
+ system_event_count=system_event_count,
55
+ created_at=datetime.now(timezone.utc),
56
+ )
57
+ tool_type_rows = [
58
+ EpisodeSyncToolTypeRecord(
59
+ sync_run_id=sync_run_id,
60
+ tool_type=tool_type,
61
+ event_count=count,
62
+ )
63
+ for tool_type, count in sorted((tool_type_counts or {}).items())
64
+ ]
65
+ return run, tool_type_rows
@@ -0,0 +1 @@
1
+ """Periphery-level request validation helpers."""
@@ -0,0 +1,253 @@
1
+ """Database-integrity compatibility checks for validated edge requests."""
2
+
3
+ from app.core.contracts.errors import ErrorCode, ErrorDetail
4
+ from app.core.contracts.requests import (
5
+ AssociationLinkUpdate,
6
+ FactUpdateLinkUpdate,
7
+ MemoryBatchUpdateRequest,
8
+ MemoryCreateAssociationLink,
9
+ MemoryCreateRequest,
10
+ MemoryUpdateRequest,
11
+ UtilityVoteUpdate,
12
+ )
13
+ from app.core.entities.memory import Memory, MemoryKind, MemoryScope
14
+ from app.core.interfaces.unit_of_work import IUnitOfWork
15
+
16
+
17
+ def _is_visible(memory: Memory, repo_id: str) -> bool:
18
+ """Determine whether a memory is visible inside a repo operation context."""
19
+
20
+ return memory.repo_id == repo_id or memory.scope == MemoryScope.GLOBAL
21
+
22
+
23
+ def _require_memory(uow: IUnitOfWork, *, memory_id: str, field: str) -> tuple[Memory | None, list[ErrorDetail]]:
24
+ """Fetch a memory row and return a not-found contract error when missing."""
25
+
26
+ memory = uow.memories.get(memory_id)
27
+ if memory is None:
28
+ return None, [ErrorDetail(code=ErrorCode.NOT_FOUND, message=f"Memory not found: {memory_id}", field=field)]
29
+ return memory, []
30
+
31
+
32
+ def _validate_evidence_refs(
33
+ uow: IUnitOfWork,
34
+ *,
35
+ repo_id: str,
36
+ refs: list[str],
37
+ field_prefix: str,
38
+ ) -> list[ErrorDetail]:
39
+ """Validate that evidence refs resolve to repo-visible episode events."""
40
+
41
+ if not refs:
42
+ return []
43
+
44
+ existing_ids = set(uow.episodes.list_existing_event_ids(event_ids=refs))
45
+ visible_ids = set(uow.episodes.list_visible_event_ids(repo_id=repo_id, event_ids=refs))
46
+ errors: list[ErrorDetail] = []
47
+
48
+ for index, ref in enumerate(refs):
49
+ field = f"{field_prefix}.{index}"
50
+ if ref not in existing_ids:
51
+ errors.append(
52
+ ErrorDetail(
53
+ code=ErrorCode.NOT_FOUND,
54
+ message=f"Episode event not found: {ref}",
55
+ field=field,
56
+ )
57
+ )
58
+ continue
59
+ if ref not in visible_ids:
60
+ errors.append(
61
+ ErrorDetail(
62
+ code=ErrorCode.INTEGRITY_ERROR,
63
+ message="Referenced episode event is not visible for this repo_id",
64
+ field=field,
65
+ )
66
+ )
67
+ return errors
68
+
69
+
70
+ def validate_create_integrity(request: MemoryCreateRequest, uow: IUnitOfWork) -> list[ErrorDetail]:
71
+ """Validate database integrity constraints for create operations."""
72
+
73
+ errors = _validate_evidence_refs(
74
+ uow,
75
+ repo_id=request.repo_id,
76
+ refs=list(request.memory.evidence_refs),
77
+ field_prefix="memory.evidence_refs",
78
+ )
79
+ links = request.memory.links
80
+ if links.problem_id:
81
+ problem_memory, problem_errors = _require_memory(uow, memory_id=links.problem_id, field="memory.links.problem_id")
82
+ errors.extend(problem_errors)
83
+ if problem_memory and not _is_visible(problem_memory, request.repo_id):
84
+ errors.append(
85
+ ErrorDetail(
86
+ code=ErrorCode.INTEGRITY_ERROR,
87
+ message="Referenced problem memory is not visible for this repo_id",
88
+ field="memory.links.problem_id",
89
+ )
90
+ )
91
+ if problem_memory and problem_memory.kind != MemoryKind.PROBLEM:
92
+ errors.append(
93
+ ErrorDetail(
94
+ code=ErrorCode.INTEGRITY_ERROR,
95
+ message="Referenced problem_id must point to a problem memory",
96
+ field="memory.links.problem_id",
97
+ )
98
+ )
99
+
100
+ for index, association in enumerate(links.associations):
101
+ target, target_errors = _require_memory(
102
+ uow,
103
+ memory_id=association.to_memory_id,
104
+ field=f"memory.links.associations.{index}.to_memory_id",
105
+ )
106
+ errors.extend(target_errors)
107
+ if target and not _is_visible(target, request.repo_id):
108
+ errors.append(
109
+ ErrorDetail(
110
+ code=ErrorCode.INTEGRITY_ERROR,
111
+ message="Association target shellbrain is not visible for this repo_id",
112
+ field=f"memory.links.associations.{index}.to_memory_id",
113
+ )
114
+ )
115
+ return errors
116
+
117
+
118
+ def validate_update_integrity(request: MemoryUpdateRequest | MemoryBatchUpdateRequest, uow: IUnitOfWork) -> list[ErrorDetail]:
119
+ """Validate database integrity constraints for update operations."""
120
+
121
+ if isinstance(request, MemoryBatchUpdateRequest):
122
+ errors: list[ErrorDetail] = []
123
+ for index, item in enumerate(request.updates):
124
+ single_errors = validate_update_integrity(
125
+ MemoryUpdateRequest(
126
+ repo_id=request.repo_id,
127
+ memory_id=item.memory_id,
128
+ update=item.update,
129
+ ),
130
+ uow,
131
+ )
132
+ for error in single_errors:
133
+ field = error.field
134
+ if field is not None:
135
+ field = f"updates.{index}.{field}"
136
+ errors.append(ErrorDetail(code=error.code, message=error.message, field=field))
137
+ return errors
138
+
139
+ errors: list[ErrorDetail] = []
140
+ target_memory, target_errors = _require_memory(uow, memory_id=request.memory_id, field="memory_id")
141
+ errors.extend(target_errors)
142
+ if target_memory and not _is_visible(target_memory, request.repo_id):
143
+ errors.append(
144
+ ErrorDetail(
145
+ code=ErrorCode.INTEGRITY_ERROR,
146
+ message="Target memory is not visible for this repo_id",
147
+ field="memory_id",
148
+ )
149
+ )
150
+
151
+ update = request.update
152
+ if isinstance(update, UtilityVoteUpdate):
153
+ errors.extend(
154
+ _validate_evidence_refs(
155
+ uow,
156
+ repo_id=request.repo_id,
157
+ refs=list(update.evidence_refs),
158
+ field_prefix="update.evidence_refs",
159
+ )
160
+ )
161
+ problem_memory, problem_errors = _require_memory(uow, memory_id=update.problem_id, field="update.problem_id")
162
+ errors.extend(problem_errors)
163
+ if problem_memory and not _is_visible(problem_memory, request.repo_id):
164
+ errors.append(
165
+ ErrorDetail(
166
+ code=ErrorCode.INTEGRITY_ERROR,
167
+ message="utility_vote.problem_id is not visible for this repo_id",
168
+ field="update.problem_id",
169
+ )
170
+ )
171
+ if problem_memory and problem_memory.kind != MemoryKind.PROBLEM:
172
+ errors.append(
173
+ ErrorDetail(
174
+ code=ErrorCode.INTEGRITY_ERROR,
175
+ message="utility_vote.problem_id must reference a problem memory",
176
+ field="update.problem_id",
177
+ )
178
+ )
179
+
180
+ if isinstance(update, FactUpdateLinkUpdate):
181
+ errors.extend(
182
+ _validate_evidence_refs(
183
+ uow,
184
+ repo_id=request.repo_id,
185
+ refs=list(update.evidence_refs),
186
+ field_prefix="update.evidence_refs",
187
+ )
188
+ )
189
+ old_fact, old_errors = _require_memory(uow, memory_id=update.old_fact_id, field="update.old_fact_id")
190
+ new_fact, new_errors = _require_memory(uow, memory_id=update.new_fact_id, field="update.new_fact_id")
191
+ errors.extend(old_errors)
192
+ errors.extend(new_errors)
193
+ if old_fact and not _is_visible(old_fact, request.repo_id):
194
+ errors.append(
195
+ ErrorDetail(
196
+ code=ErrorCode.INTEGRITY_ERROR,
197
+ message="old_fact_id is not visible for this repo_id",
198
+ field="update.old_fact_id",
199
+ )
200
+ )
201
+ if old_fact and old_fact.kind != MemoryKind.FACT:
202
+ errors.append(
203
+ ErrorDetail(
204
+ code=ErrorCode.INTEGRITY_ERROR,
205
+ message="old_fact_id must reference a fact memory",
206
+ field="update.old_fact_id",
207
+ )
208
+ )
209
+ if new_fact and not _is_visible(new_fact, request.repo_id):
210
+ errors.append(
211
+ ErrorDetail(
212
+ code=ErrorCode.INTEGRITY_ERROR,
213
+ message="new_fact_id is not visible for this repo_id",
214
+ field="update.new_fact_id",
215
+ )
216
+ )
217
+ if new_fact and new_fact.kind != MemoryKind.FACT:
218
+ errors.append(
219
+ ErrorDetail(
220
+ code=ErrorCode.INTEGRITY_ERROR,
221
+ message="new_fact_id must reference a fact memory",
222
+ field="update.new_fact_id",
223
+ )
224
+ )
225
+ if target_memory and target_memory.kind != MemoryKind.CHANGE:
226
+ errors.append(
227
+ ErrorDetail(
228
+ code=ErrorCode.INTEGRITY_ERROR,
229
+ message="memory_id must reference a change memory for fact_update_link",
230
+ field="memory_id",
231
+ )
232
+ )
233
+
234
+ if isinstance(update, AssociationLinkUpdate):
235
+ errors.extend(
236
+ _validate_evidence_refs(
237
+ uow,
238
+ repo_id=request.repo_id,
239
+ refs=list(update.evidence_refs),
240
+ field_prefix="update.evidence_refs",
241
+ )
242
+ )
243
+ target, target_errors = _require_memory(uow, memory_id=update.to_memory_id, field="update.to_memory_id")
244
+ errors.extend(target_errors)
245
+ if target and not _is_visible(target, request.repo_id):
246
+ errors.append(
247
+ ErrorDetail(
248
+ code=ErrorCode.INTEGRITY_ERROR,
249
+ message="association_link.to_memory_id is not visible for this repo_id",
250
+ field="update.to_memory_id",
251
+ )
252
+ )
253
+ return errors
@@ -0,0 +1,94 @@
1
+ """Semantic validation checks for operation requests at the system edge."""
2
+
3
+ from app.core.contracts.errors import ErrorCode, ErrorDetail
4
+ from app.core.contracts.requests import (
5
+ AssociationLinkUpdate,
6
+ FactUpdateLinkUpdate,
7
+ MemoryBatchUpdateRequest,
8
+ MemoryCreateRequest,
9
+ MemoryUpdateRequest,
10
+ )
11
+ from app.core.entities.memory import MemoryKind
12
+
13
+
14
+ def validate_create_semantics(request: MemoryCreateRequest) -> list[ErrorDetail]:
15
+ """Validate semantic constraints for create operations."""
16
+
17
+ errors: list[ErrorDetail] = []
18
+ kind = MemoryKind(request.memory.kind)
19
+ problem_id = request.memory.links.problem_id
20
+ if kind in {MemoryKind.SOLUTION, MemoryKind.FAILED_TACTIC} and not problem_id:
21
+ errors.append(
22
+ ErrorDetail(
23
+ code=ErrorCode.SEMANTIC_ERROR,
24
+ message="links.problem_id is required for solution and failed_tactic memories",
25
+ field="memory.links.problem_id",
26
+ )
27
+ )
28
+ if kind in {MemoryKind.PROBLEM, MemoryKind.FACT, MemoryKind.PREFERENCE, MemoryKind.CHANGE} and problem_id:
29
+ errors.append(
30
+ ErrorDetail(
31
+ code=ErrorCode.SEMANTIC_ERROR,
32
+ message="links.problem_id is only valid for solution and failed_tactic memories",
33
+ field="memory.links.problem_id",
34
+ )
35
+ )
36
+
37
+ seen_pairs: set[tuple[str, str]] = set()
38
+ for index, association in enumerate(request.memory.links.associations):
39
+ pair = (association.to_memory_id, association.relation_type)
40
+ if pair in seen_pairs:
41
+ errors.append(
42
+ ErrorDetail(
43
+ code=ErrorCode.SEMANTIC_ERROR,
44
+ message="Duplicate association link entries are not allowed",
45
+ field=f"memory.links.associations.{index}",
46
+ )
47
+ )
48
+ seen_pairs.add(pair)
49
+ return errors
50
+
51
+
52
+ def validate_update_semantics(request: MemoryUpdateRequest | MemoryBatchUpdateRequest) -> list[ErrorDetail]:
53
+ """Validate semantic constraints for update operations."""
54
+
55
+ errors: list[ErrorDetail] = []
56
+ if isinstance(request, MemoryBatchUpdateRequest):
57
+ problem_ids = {item.update.problem_id for item in request.updates}
58
+ if len(problem_ids) > 1:
59
+ errors.append(
60
+ ErrorDetail(
61
+ code=ErrorCode.SEMANTIC_ERROR,
62
+ message="Batch utility votes must share the same problem_id",
63
+ field="updates",
64
+ )
65
+ )
66
+ return errors
67
+
68
+ update = request.update
69
+ if isinstance(update, AssociationLinkUpdate) and update.to_memory_id == request.memory_id:
70
+ errors.append(
71
+ ErrorDetail(
72
+ code=ErrorCode.SEMANTIC_ERROR,
73
+ message="association links cannot self-reference the source memory",
74
+ field="update.to_memory_id",
75
+ )
76
+ )
77
+ if isinstance(update, FactUpdateLinkUpdate):
78
+ if update.old_fact_id == update.new_fact_id:
79
+ errors.append(
80
+ ErrorDetail(
81
+ code=ErrorCode.SEMANTIC_ERROR,
82
+ message="fact_update_link requires different old_fact_id and new_fact_id values",
83
+ field="update.new_fact_id",
84
+ )
85
+ )
86
+ if update.old_fact_id == request.memory_id or update.new_fact_id == request.memory_id:
87
+ errors.append(
88
+ ErrorDetail(
89
+ code=ErrorCode.SEMANTIC_ERROR,
90
+ message="memory_id is the change shellbrain id and cannot equal old_fact_id/new_fact_id",
91
+ field="memory_id",
92
+ )
93
+ )
94
+ return errors