ssot-core 0.2.15.dev1__tar.gz → 0.2.16.dev1__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.
Files changed (114) hide show
  1. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/PKG-INFO +3 -3
  2. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/pyproject.toml +3 -3
  3. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_core.egg-info/PKG-INFO +3 -3
  4. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_core.egg-info/SOURCES.txt +1 -0
  5. ssot_core-0.2.16.dev1/src/ssot_core.egg-info/requires.txt +5 -0
  6. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/init.py +2 -2
  7. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/save.py +2 -2
  8. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/status_sync.py +27 -2
  9. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/__init__.py +5 -1
  10. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/errors.py +4 -0
  11. ssot_core-0.2.16.dev1/src/ssot_registry/util/registry_lock.py +137 -0
  12. ssot_core-0.2.15.dev1/src/ssot_core.egg-info/requires.txt +0 -5
  13. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/README.md +0 -0
  14. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/setup.cfg +0 -0
  15. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_core.egg-info/dependency_links.txt +0 -0
  16. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_core.egg-info/top_level.txt +0 -0
  17. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/__init__.py +0 -0
  18. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/__main__.py +0 -0
  19. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/__init__.py +0 -0
  20. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/boundary.py +0 -0
  21. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/claims.py +0 -0
  22. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/documents.py +0 -0
  23. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/entity_ops.py +0 -0
  24. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/evidence.py +0 -0
  25. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/graph.py +0 -0
  26. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/lifecycle.py +0 -0
  27. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/load.py +0 -0
  28. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/plan.py +0 -0
  29. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/profile_eval.py +0 -0
  30. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/profile_resolution.py +0 -0
  31. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/registry.py +0 -0
  32. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/release.py +0 -0
  33. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/test_execution.py +0 -0
  34. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/upgrade.py +0 -0
  35. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/api/validate.py +0 -0
  36. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/__init__.py +0 -0
  37. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/adr_cmd.py +0 -0
  38. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/boundary_cmd.py +0 -0
  39. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/claim_cmd.py +0 -0
  40. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/common.py +0 -0
  41. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/evidence_cmd.py +0 -0
  42. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/feature_cmd.py +0 -0
  43. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/graph_cmd.py +0 -0
  44. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/init_cmd.py +0 -0
  45. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/issue_cmd.py +0 -0
  46. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/main.py +0 -0
  47. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/profile_cmd.py +0 -0
  48. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/registry_cmd.py +0 -0
  49. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/release_cmd.py +0 -0
  50. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/risk_cmd.py +0 -0
  51. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/spec_cmd.py +0 -0
  52. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/test_cmd.py +0 -0
  53. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/upgrade_cmd.py +0 -0
  54. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/cli/validate_cmd.py +0 -0
  55. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/graph/__init__.py +0 -0
  56. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/graph/export_dot.py +0 -0
  57. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/graph/export_json.py +0 -0
  58. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/__init__.py +0 -0
  59. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/certification.py +0 -0
  60. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/claim_closure.py +0 -0
  61. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/document_lifecycle.py +0 -0
  62. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/document_supersession.py +0 -0
  63. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/feature_requirements.py +0 -0
  64. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/lifecycle.py +0 -0
  65. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/profile_requirements.py +0 -0
  66. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/promotion.py +0 -0
  67. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/guards/publication.py +0 -0
  68. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/__init__.py +0 -0
  69. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/boundary.py +0 -0
  70. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/claim.py +0 -0
  71. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/document.py +0 -0
  72. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/enums.py +0 -0
  73. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/evidence.py +0 -0
  74. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/feature.py +0 -0
  75. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/ids.py +0 -0
  76. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/issue.py +0 -0
  77. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/profile.py +0 -0
  78. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/registry.py +0 -0
  79. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/release.py +0 -0
  80. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/risk.py +0 -0
  81. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/schema_version.py +0 -0
  82. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/model/test.py +0 -0
  83. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/reports/__init__.py +0 -0
  84. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/reports/certification_report.py +0 -0
  85. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/reports/summary.py +0 -0
  86. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/reports/validation_report.py +0 -0
  87. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/snapshots/__init__.py +0 -0
  88. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/snapshots/boundary_snapshot.py +0 -0
  89. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/snapshots/hashing.py +0 -0
  90. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/snapshots/published_snapshot.py +0 -0
  91. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/snapshots/release_snapshot.py +0 -0
  92. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/templates/__init__.py +0 -0
  93. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/templates/registry.full.json +0 -0
  94. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/templates/registry.minimal.json +0 -0
  95. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/document_io.py +0 -0
  96. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/formatting.py +0 -0
  97. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/fs.py +0 -0
  98. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/jcs.py +0 -0
  99. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/jsonio.py +0 -0
  100. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/util/time.py +0 -0
  101. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/__init__.py +0 -0
  102. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/bidirectional.py +0 -0
  103. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/bounds.py +0 -0
  104. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/coverage.py +0 -0
  105. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/documents.py +0 -0
  106. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/filesystem.py +0 -0
  107. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/identity.py +0 -0
  108. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/lifecycle.py +0 -0
  109. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/promotion.py +0 -0
  110. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/references.py +0 -0
  111. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/reservations.py +0 -0
  112. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/structure.py +0 -0
  113. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/validators/tiers.py +0 -0
  114. {ssot_core-0.2.15.dev1 → ssot_core-0.2.16.dev1}/src/ssot_registry/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssot-core
3
- Version: 0.2.15.dev1
3
+ Version: 0.2.16.dev1
4
4
  Summary: Core Python runtime, registry model, validation, and release workflow APIs for SSOT.
5
5
  Author-email: Jacob Stewart <jacob@swarmauri.com>
6
6
  License-Expression: Apache-2.0
@@ -34,8 +34,8 @@ Classifier: Topic :: System :: Archiving
34
34
  Classifier: Topic :: Utilities
35
35
  Requires-Python: <3.14,>=3.10
36
36
  Description-Content-Type: text/markdown
37
- Requires-Dist: ssot-contracts==0.2.15.dev1
38
- Requires-Dist: ssot-views==0.2.15.dev1
37
+ Requires-Dist: ssot-contracts==0.2.16.dev1
38
+ Requires-Dist: ssot-views==0.2.16.dev1
39
39
  Requires-Dist: tomli>=2.0.1; python_version < "3.11"
40
40
 
41
41
  <div align="center">
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ssot-core"
7
- version = "0.2.15.dev1"
7
+ version = "0.2.16.dev1"
8
8
  description = "Core Python runtime, registry model, validation, and release workflow APIs for SSOT."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<3.14"
11
11
  license = "Apache-2.0"
12
12
  authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
13
13
  dependencies = [
14
- "ssot-contracts==0.2.15.dev1",
15
- "ssot-views==0.2.15.dev1",
14
+ "ssot-contracts==0.2.16.dev1",
15
+ "ssot-views==0.2.16.dev1",
16
16
  "tomli>=2.0.1; python_version < '3.11'",
17
17
  ]
18
18
  keywords = ["ssot", "core", "registry", "validation", "release-management", "governance", "compliance"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssot-core
3
- Version: 0.2.15.dev1
3
+ Version: 0.2.16.dev1
4
4
  Summary: Core Python runtime, registry model, validation, and release workflow APIs for SSOT.
5
5
  Author-email: Jacob Stewart <jacob@swarmauri.com>
6
6
  License-Expression: Apache-2.0
@@ -34,8 +34,8 @@ Classifier: Topic :: System :: Archiving
34
34
  Classifier: Topic :: Utilities
35
35
  Requires-Python: <3.14,>=3.10
36
36
  Description-Content-Type: text/markdown
37
- Requires-Dist: ssot-contracts==0.2.15.dev1
38
- Requires-Dist: ssot-views==0.2.15.dev1
37
+ Requires-Dist: ssot-contracts==0.2.16.dev1
38
+ Requires-Dist: ssot-views==0.2.16.dev1
39
39
  Requires-Dist: tomli>=2.0.1; python_version < "3.11"
40
40
 
41
41
  <div align="center">
@@ -94,6 +94,7 @@ src/ssot_registry/util/formatting.py
94
94
  src/ssot_registry/util/fs.py
95
95
  src/ssot_registry/util/jcs.py
96
96
  src/ssot_registry/util/jsonio.py
97
+ src/ssot_registry/util/registry_lock.py
97
98
  src/ssot_registry/util/time.py
98
99
  src/ssot_registry/validators/__init__.py
99
100
  src/ssot_registry/validators/bidirectional.py
@@ -0,0 +1,5 @@
1
+ ssot-contracts==0.2.16.dev1
2
+ ssot-views==0.2.16.dev1
3
+
4
+ [:python_version < "3.11"]
5
+ tomli>=2.0.1
@@ -6,7 +6,7 @@ from ssot_contracts.schema import list_schema_names, load_schema_text
6
6
  from ssot_registry.api.documents import sync_all_documents
7
7
  from ssot_registry.model.registry import build_minimal_registry, default_paths
8
8
  from ssot_registry.util.errors import RegistryError
9
- from ssot_registry.util.jsonio import save_json
9
+ from .save import save_registry
10
10
  from .validate import validate_registry
11
11
 
12
12
 
@@ -36,7 +36,7 @@ def initialize_repo(
36
36
  (repo_root / relative_path).mkdir(parents=True, exist_ok=True)
37
37
 
38
38
  registry = build_minimal_registry(repo_id, repo_name, version)
39
- save_json(registry_path, registry)
39
+ save_registry(registry_path, registry)
40
40
 
41
41
  _copy_schema_tree(repo_root / paths["schema_root"])
42
42
  sync_all_documents(registry_path)
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
4
 
5
- from ssot_registry.util.jsonio import save_json
5
+ from ssot_registry.util.registry_lock import save_registry_json_locked
6
6
 
7
7
 
8
8
  def save_registry(registry_path: str | Path, registry: dict[str, object]) -> None:
9
- save_json(registry_path, registry)
9
+ save_registry_json_locked(registry_path, registry)
@@ -18,6 +18,13 @@ CLAIM_TERMINAL_STATUSES = {"retired"}
18
18
  CLAIM_RELEASE_STATUSES = {"promoted", "published"}
19
19
 
20
20
 
21
+ def _is_planned_path(path: object, marker: str) -> bool:
22
+ if not isinstance(path, str):
23
+ return False
24
+ normalized = path.replace("\\", "/")
25
+ return marker in normalized
26
+
27
+
21
28
  def _build_current_index(registry: dict[str, Any]) -> dict[str, dict[str, dict[str, object]]]:
22
29
  failures: list[str] = []
23
30
  return build_index(registry, failures)
@@ -65,7 +72,10 @@ def _sync_evidence(registry: dict[str, Any], repo_root: Path) -> list[dict[str,
65
72
  evidence_id = evidence["id"]
66
73
  reason = "evidence artifact exists and linked claim tiers are satisfied"
67
74
  status = "passed"
68
- if not (repo_root / evidence["path"]).exists():
75
+ if _is_planned_path(evidence.get("path"), "/evidence/planned/"):
76
+ status = "planned"
77
+ reason = "evidence path is a planned placeholder"
78
+ elif not (repo_root / evidence["path"]).exists():
69
79
  status = "planned"
70
80
  reason = "evidence artifact path does not exist"
71
81
  elif _missing_refs(evidence, "claim_ids", index["claims"]) or _missing_refs(evidence, "test_ids", index["tests"]):
@@ -95,7 +105,10 @@ def _sync_tests(registry: dict[str, Any], repo_root: Path) -> list[dict[str, obj
95
105
  test_id = test["id"]
96
106
  reason = "test path exists and linked evidence is passed"
97
107
  status = "passing"
98
- if not (repo_root / test["path"]).exists():
108
+ if _is_planned_path(test.get("path"), "tests/planned/"):
109
+ status = "planned"
110
+ reason = "test path is a planned placeholder"
111
+ elif not (repo_root / test["path"]).exists():
99
112
  status = "planned"
100
113
  reason = "test path does not exist"
101
114
  elif _missing_refs(test, "feature_ids", index["features"]) or _missing_refs(test, "claim_ids", index["claims"]):
@@ -133,6 +146,10 @@ def _claim_support_status(claim: dict[str, Any], index: dict[str, dict[str, dict
133
146
  linked_evidence = [index["evidence"][evidence_id] for evidence_id in claim.get("evidence_ids", []) if evidence_id in index["evidence"]]
134
147
  if linked_evidence and any(evidence.get("status") in {"failed", "stale"} for evidence in linked_evidence):
135
148
  return "blocked", "claim has failed or stale linked evidence"
149
+ if (linked_tests or linked_evidence) and not any(test.get("status") != "planned" for test in linked_tests) and not any(
150
+ evidence.get("status") != "planned" for evidence in linked_evidence
151
+ ):
152
+ return "proposed", "claim has only planned verification support"
136
153
  if linked_tests and all(test.get("status") == "passing" for test in linked_tests) and linked_evidence:
137
154
  tier_met = any(
138
155
  evidence.get("status") == "passed" and CLAIM_TIER_RANK[evidence["tier"]] >= CLAIM_TIER_RANK[claim["tier"]]
@@ -191,9 +208,17 @@ def _sync_features_once(registry: dict[str, Any]) -> list[dict[str, object]]:
191
208
  tests_pass = bool(linked_tests) and all(test.get("status") == "passing" for test in linked_tests)
192
209
  claims_pass = any(_claim_satisfies_feature(claim, required_tier) for claim in linked_claims)
193
210
  requirements_pass = all(required.get("implementation_status") == "implemented" for required in required_features)
211
+ only_planned_support = (
212
+ bool(linked_tests or linked_claims)
213
+ and all(test.get("status") == "planned" for test in linked_tests)
214
+ and all(CLAIM_STATUS_RANK.get(claim.get("status"), -999) <= CLAIM_STATUS_RANK["proposed"] for claim in linked_claims)
215
+ )
194
216
  if tests_pass and claims_pass and requirements_pass:
195
217
  status = "implemented"
196
218
  reason = "feature has passing tests, satisfying claims, and implemented requirements"
219
+ elif only_planned_support:
220
+ status = "absent"
221
+ reason = "feature has only planned verification support"
197
222
  elif linked_tests or linked_claims or required_features:
198
223
  status = "partial"
199
224
  reason = "feature has linked support but does not yet satisfy implementation criteria"
@@ -1,14 +1,18 @@
1
- from .errors import GuardError, RegistryError, ValidationError
1
+ from .errors import GuardError, RegistryError, RegistryLockError, ValidationError
2
2
  from .fs import ensure_directory, resolve_registry_path, repo_root_from_registry_path, sha256_path
3
3
  from .jsonio import load_json, save_json, stable_json_dumps
4
+ from .registry_lock import RegistryFileLock, registry_lock_path
4
5
  from .time import utc_now_iso
5
6
 
6
7
  __all__ = [
7
8
  "GuardError",
8
9
  "RegistryError",
10
+ "RegistryLockError",
9
11
  "ValidationError",
12
+ "RegistryFileLock",
10
13
  "ensure_directory",
11
14
  "load_json",
15
+ "registry_lock_path",
12
16
  "repo_root_from_registry_path",
13
17
  "resolve_registry_path",
14
18
  "save_json",
@@ -8,3 +8,7 @@ class ValidationError(RegistryError):
8
8
 
9
9
  class GuardError(RegistryError):
10
10
  """Raised when a gate or fence refuses a state transition."""
11
+
12
+
13
+ class RegistryLockError(RegistryError):
14
+ """Raised when the registry file lock cannot be acquired or released."""
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import time
7
+ import uuid
8
+ from contextlib import AbstractContextManager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ssot_registry.util.errors import RegistryLockError
14
+ from ssot_registry.util.jsonio import stable_json_dumps
15
+
16
+ DEFAULT_REGISTRY_LOCK_TTL_SECONDS = 300
17
+
18
+
19
+ def _utc_epoch_seconds() -> float:
20
+ return time.time()
21
+
22
+
23
+ def registry_lock_path(registry_path: str | Path) -> Path:
24
+ return Path(registry_path).with_name(f"{Path(registry_path).name}.lock")
25
+
26
+
27
+ def _read_lock_metadata(lock_path: Path) -> dict[str, Any]:
28
+ try:
29
+ payload = json.loads(lock_path.read_text(encoding="utf-8"))
30
+ except (OSError, json.JSONDecodeError):
31
+ return {}
32
+ return payload if isinstance(payload, dict) else {}
33
+
34
+
35
+ def _is_stale(lock_path: Path, now: float) -> bool:
36
+ metadata = _read_lock_metadata(lock_path)
37
+ expires_at = metadata.get("expires_at_epoch")
38
+ if isinstance(expires_at, int | float):
39
+ return float(expires_at) <= now
40
+ try:
41
+ stat = lock_path.stat()
42
+ except OSError:
43
+ return False
44
+ return stat.st_mtime + DEFAULT_REGISTRY_LOCK_TTL_SECONDS <= now
45
+
46
+
47
+ @dataclass
48
+ class RegistryFileLock(AbstractContextManager["RegistryFileLock"]):
49
+ registry_path: Path
50
+ reason: str = "registry mutation"
51
+ ttl_seconds: int = DEFAULT_REGISTRY_LOCK_TTL_SECONDS
52
+ owner: str | None = None
53
+
54
+ def __post_init__(self) -> None:
55
+ self.registry_path = Path(self.registry_path)
56
+ self.lock_path = registry_lock_path(self.registry_path)
57
+ self.token = uuid.uuid4().hex
58
+ self.owner = self.owner or f"pid:{os.getpid()}"
59
+ self._acquired = False
60
+
61
+ def __enter__(self) -> "RegistryFileLock":
62
+ self.acquire()
63
+ return self
64
+
65
+ def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
66
+ self.release()
67
+
68
+ def acquire(self) -> None:
69
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
70
+ now = _utc_epoch_seconds()
71
+ metadata = {
72
+ "target": self.registry_path.as_posix(),
73
+ "owner": self.owner,
74
+ "pid": os.getpid(),
75
+ "reason": self.reason,
76
+ "token": self.token,
77
+ "created_at_epoch": now,
78
+ "expires_at_epoch": now + self.ttl_seconds,
79
+ }
80
+ payload = stable_json_dumps(metadata)
81
+ while True:
82
+ try:
83
+ fd = os.open(str(self.lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
84
+ except FileExistsError as exc:
85
+ if _is_stale(self.lock_path, _utc_epoch_seconds()):
86
+ try:
87
+ self.lock_path.unlink()
88
+ except FileNotFoundError:
89
+ continue
90
+ except OSError as unlink_error:
91
+ raise RegistryLockError(f"Failed to remove stale registry lock {self.lock_path}: {unlink_error}") from unlink_error
92
+ continue
93
+ holder = _read_lock_metadata(self.lock_path)
94
+ owner = holder.get("owner", "unknown")
95
+ reason = holder.get("reason", "unknown")
96
+ raise RegistryLockError(f"Registry is locked by {owner} for {reason}: {self.lock_path}") from exc
97
+ with os.fdopen(fd, "w", encoding="utf-8") as lock_file:
98
+ lock_file.write(payload)
99
+ lock_file.flush()
100
+ os.fsync(lock_file.fileno())
101
+ self._acquired = True
102
+ return
103
+
104
+ def release(self) -> None:
105
+ if not self._acquired:
106
+ return
107
+ metadata = _read_lock_metadata(self.lock_path)
108
+ if metadata.get("token") != self.token:
109
+ raise RegistryLockError(f"Registry lock owner mismatch; refusing to release {self.lock_path}")
110
+ try:
111
+ self.lock_path.unlink()
112
+ except FileNotFoundError:
113
+ pass
114
+ self._acquired = False
115
+
116
+
117
+ def save_registry_json_locked(registry_path: str | Path, registry: dict[str, object], *, reason: str = "registry mutation") -> None:
118
+ target = Path(registry_path)
119
+ target.parent.mkdir(parents=True, exist_ok=True)
120
+ with RegistryFileLock(target, reason=reason):
121
+ payload = stable_json_dumps(registry)
122
+ temp_name: str | None = None
123
+ try:
124
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=target.parent, prefix=f".{target.name}.", suffix=".tmp", delete=False) as temp_file:
125
+ temp_name = temp_file.name
126
+ temp_file.write(payload)
127
+ temp_file.flush()
128
+ os.fsync(temp_file.fileno())
129
+ os.replace(temp_name, target)
130
+ finally:
131
+ if temp_name is not None:
132
+ temp_path = Path(temp_name)
133
+ if temp_path.exists():
134
+ try:
135
+ temp_path.unlink()
136
+ except OSError:
137
+ pass
@@ -1,5 +0,0 @@
1
- ssot-contracts==0.2.15.dev1
2
- ssot-views==0.2.15.dev1
3
-
4
- [:python_version < "3.11"]
5
- tomli>=2.0.1