rocket-engine 0.0.2b1__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 (241) hide show
  1. rocket_engine/__init__.py +8 -0
  2. rocket_engine/__main__.py +12 -0
  3. rocket_engine/adapters/README.md +54 -0
  4. rocket_engine/adapters/__init__.py +7 -0
  5. rocket_engine/adapters/api.py +39 -0
  6. rocket_engine/adapters/base.py +46 -0
  7. rocket_engine/adapters/cace/__init__.py +163 -0
  8. rocket_engine/adapters/circulax/README.md +10 -0
  9. rocket_engine/adapters/circulax/__init__.py +112 -0
  10. rocket_engine/adapters/circulax/_driver.py +190 -0
  11. rocket_engine/adapters/circulax/adapter.py +538 -0
  12. rocket_engine/adapters/circulax/results.py +291 -0
  13. rocket_engine/adapters/cli/README.md +34 -0
  14. rocket_engine/adapters/cli/__init__.py +305 -0
  15. rocket_engine/adapters/connectivity/__init__.py +196 -0
  16. rocket_engine/adapters/connectivity/results.py +214 -0
  17. rocket_engine/adapters/cosim/README.md +9 -0
  18. rocket_engine/adapters/cosim/__init__.py +1390 -0
  19. rocket_engine/adapters/cosim/results.py +214 -0
  20. rocket_engine/adapters/devsim/__init__.py +171 -0
  21. rocket_engine/adapters/digital_flow/README.md +9 -0
  22. rocket_engine/adapters/digital_flow/__init__.py +1121 -0
  23. rocket_engine/adapters/digital_flow/results.py +227 -0
  24. rocket_engine/adapters/digital_flow/state.py +462 -0
  25. rocket_engine/adapters/drc/README.klayout.md +7 -0
  26. rocket_engine/adapters/drc/README.md +7 -0
  27. rocket_engine/adapters/drc/__init__.py +1 -0
  28. rocket_engine/adapters/drc/klayout.py +952 -0
  29. rocket_engine/adapters/drc/magic.py +287 -0
  30. rocket_engine/adapters/drc/results.py +184 -0
  31. rocket_engine/adapters/drc/results_kl.py +188 -0
  32. rocket_engine/adapters/elmer/__init__.py +163 -0
  33. rocket_engine/adapters/envelope.py +120 -0
  34. rocket_engine/adapters/equivalence/__init__.py +156 -0
  35. rocket_engine/adapters/errors.py +181 -0
  36. rocket_engine/adapters/extract_magic/README.md +15 -0
  37. rocket_engine/adapters/extract_magic/__init__.py +425 -0
  38. rocket_engine/adapters/extract_magic/results.py +209 -0
  39. rocket_engine/adapters/formal_sby/README.md +7 -0
  40. rocket_engine/adapters/formal_sby/__init__.py +773 -0
  41. rocket_engine/adapters/formal_sby/formal_edge.py +195 -0
  42. rocket_engine/adapters/formal_sby/results.py +185 -0
  43. rocket_engine/adapters/gdsfactory/__init__.py +194 -0
  44. rocket_engine/adapters/gdsfactory/_driver.py +459 -0
  45. rocket_engine/adapters/gdsfactory/adapter.py +609 -0
  46. rocket_engine/adapters/gdsfactory/results.py +181 -0
  47. rocket_engine/adapters/hdl_sim/README.md +7 -0
  48. rocket_engine/adapters/hdl_sim/__init__.py +369 -0
  49. rocket_engine/adapters/hdl_sim/ghdl.py +155 -0
  50. rocket_engine/adapters/hdl_sim/results.py +178 -0
  51. rocket_engine/adapters/hdl_sim/verilator.py +167 -0
  52. rocket_engine/adapters/http/README.md +15 -0
  53. rocket_engine/adapters/http/__init__.py +458 -0
  54. rocket_engine/adapters/http/events.py +63 -0
  55. rocket_engine/adapters/ipc/README.md +9 -0
  56. rocket_engine/adapters/ipc/__init__.py +111 -0
  57. rocket_engine/adapters/lvs_netgen/README.md +20 -0
  58. rocket_engine/adapters/lvs_netgen/__init__.py +889 -0
  59. rocket_engine/adapters/lvs_netgen/results.py +168 -0
  60. rocket_engine/adapters/mcp/README.md +19 -0
  61. rocket_engine/adapters/mcp/__init__.py +85 -0
  62. rocket_engine/adapters/mcp/api-schema.json +6107 -0
  63. rocket_engine/adapters/mcp/pull.py +246 -0
  64. rocket_engine/adapters/mcp/server.py +794 -0
  65. rocket_engine/adapters/meep/__init__.py +167 -0
  66. rocket_engine/adapters/meow/README.md +7 -0
  67. rocket_engine/adapters/meow/__init__.py +190 -0
  68. rocket_engine/adapters/meow/_driver.py +227 -0
  69. rocket_engine/adapters/meow/_envelope.py +108 -0
  70. rocket_engine/adapters/meow/adapter.py +491 -0
  71. rocket_engine/adapters/meow/results.py +218 -0
  72. rocket_engine/adapters/mutation/__init__.py +192 -0
  73. rocket_engine/adapters/ngspice/README.md +28 -0
  74. rocket_engine/adapters/ngspice/__init__.py +627 -0
  75. rocket_engine/adapters/ngspice/results.py +215 -0
  76. rocket_engine/adapters/postlayout/README.md +28 -0
  77. rocket_engine/adapters/postlayout/__init__.py +17 -0
  78. rocket_engine/adapters/postlayout/adapter.py +1062 -0
  79. rocket_engine/adapters/postlayout/results.py +330 -0
  80. rocket_engine/adapters/postlayout/translate.py +71 -0
  81. rocket_engine/adapters/profile.py +220 -0
  82. rocket_engine/adapters/sax/README.md +23 -0
  83. rocket_engine/adapters/sax/__init__.py +516 -0
  84. rocket_engine/adapters/sax/_driver.py +148 -0
  85. rocket_engine/adapters/sax/results.py +291 -0
  86. rocket_engine/adapters/stdio_console.py +1151 -0
  87. rocket_engine/adapters/syntax/README.md +5 -0
  88. rocket_engine/adapters/syntax/__init__.py +366 -0
  89. rocket_engine/adapters/syntax/results.py +181 -0
  90. rocket_engine/adapters/synth_yosys/README.md +7 -0
  91. rocket_engine/adapters/synth_yosys/__init__.py +655 -0
  92. rocket_engine/adapters/synth_yosys/amaranth.py +120 -0
  93. rocket_engine/adapters/synth_yosys/ghdl.py +128 -0
  94. rocket_engine/adapters/synth_yosys/results.py +173 -0
  95. rocket_engine/adapters/timing_sta/README.md +9 -0
  96. rocket_engine/adapters/timing_sta/__init__.py +726 -0
  97. rocket_engine/adapters/view_sync/__init__.py +185 -0
  98. rocket_engine/adapters/view_sync/results.py +189 -0
  99. rocket_engine/adapters/vlsir/__init__.py +142 -0
  100. rocket_engine/adapters/xschem/__init__.py +440 -0
  101. rocket_engine/adapters/xschem/deck.py +266 -0
  102. rocket_engine/clients/__init__.py +5 -0
  103. rocket_engine/clients/runtime.py +255 -0
  104. rocket_engine/composition.py +2279 -0
  105. rocket_engine/execution/README.md +30 -0
  106. rocket_engine/execution/__init__.py +150 -0
  107. rocket_engine/execution/apig/__init__.py +1 -0
  108. rocket_engine/execution/apig/pool.py +463 -0
  109. rocket_engine/execution/apig/submit.py +124 -0
  110. rocket_engine/execution/block_runs_scope.py +479 -0
  111. rocket_engine/execution/coalesce.py +414 -0
  112. rocket_engine/execution/console/__init__.py +1 -0
  113. rocket_engine/execution/console/apply_session.py +318 -0
  114. rocket_engine/execution/console/lifecycle.py +265 -0
  115. rocket_engine/execution/dispatch.py +205 -0
  116. rocket_engine/execution/errors.py +31 -0
  117. rocket_engine/execution/ports.py +797 -0
  118. rocket_engine/execution/pyexec/__init__.py +1 -0
  119. rocket_engine/execution/pyexec/apply_context.py +547 -0
  120. rocket_engine/execution/pyexec/approvals.py +173 -0
  121. rocket_engine/execution/pyexec/runner.py +404 -0
  122. rocket_engine/execution/pyexec/support.py +464 -0
  123. rocket_engine/execution/python_exec.py +897 -0
  124. rocket_engine/execution/queue.py +85 -0
  125. rocket_engine/execution/replay_safe.py +601 -0
  126. rocket_engine/execution/routing/__init__.py +1 -0
  127. rocket_engine/execution/routing/calls.py +364 -0
  128. rocket_engine/execution/routing/namespace.py +785 -0
  129. rocket_engine/execution/routing/ports.py +102 -0
  130. rocket_engine/execution/routing/tools.py +346 -0
  131. rocket_engine/execution/runner.py +311 -0
  132. rocket_engine/execution/runtime.py +1149 -0
  133. rocket_engine/execution/service.py +279 -0
  134. rocket_engine/execution/worker.py +464 -0
  135. rocket_engine/interfaces/README.md +26 -0
  136. rocket_engine/interfaces/__init__.py +58 -0
  137. rocket_engine/interfaces/apig_assets.py +407 -0
  138. rocket_engine/interfaces/celllink.py +461 -0
  139. rocket_engine/interfaces/documents.py +92 -0
  140. rocket_engine/interfaces/envelopes.py +131 -0
  141. rocket_engine/interfaces/errors.py +16 -0
  142. rocket_engine/interfaces/layout.py +410 -0
  143. rocket_engine/interfaces/layout_schema.yaml +278 -0
  144. rocket_engine/interfaces/paths.py +223 -0
  145. rocket_engine/interfaces/pdks.py +550 -0
  146. rocket_engine/interfaces/ports.py +72 -0
  147. rocket_engine/interfaces/references.py +115 -0
  148. rocket_engine/interfaces/refs.py +85 -0
  149. rocket_engine/interfaces/runparams.py +304 -0
  150. rocket_engine/interfaces/setup.py +552 -0
  151. rocket_engine/interfaces/setup_catalogue.yaml +243 -0
  152. rocket_engine/interfaces/setup_probes.py +66 -0
  153. rocket_engine/interfaces/vocabulary.py +58 -0
  154. rocket_engine/interfaces/yaml_dump.py +31 -0
  155. rocket_engine/library/README.md +22 -0
  156. rocket_engine/library/__init__.py +13 -0
  157. rocket_engine/library/apig_catalog.py +371 -0
  158. rocket_engine/library/catalog.py +185 -0
  159. rocket_engine/library/errors.py +16 -0
  160. rocket_engine/mutate/__init__.py +776 -0
  161. rocket_engine/mutate/operators.py +700 -0
  162. rocket_engine/ops/__init__.py +1 -0
  163. rocket_engine/ops/deferred.py +279 -0
  164. rocket_engine/ops/editors.py +404 -0
  165. rocket_engine/ops/evidence.py +194 -0
  166. rocket_engine/ops/gates.py +262 -0
  167. rocket_engine/ops/ports.py +76 -0
  168. rocket_engine/ops/queries.py +417 -0
  169. rocket_engine/ops/runs.py +553 -0
  170. rocket_engine/ops/submit.py +614 -0
  171. rocket_engine/project/README.md +27 -0
  172. rocket_engine/project/__init__.py +6 -0
  173. rocket_engine/project/canvas/__init__.py +55 -0
  174. rocket_engine/project/canvas/compile.py +109 -0
  175. rocket_engine/project/canvas/integrate.py +48 -0
  176. rocket_engine/project/canvas/schema.py +241 -0
  177. rocket_engine/project/canvas/templates.py +46 -0
  178. rocket_engine/project/canvas/validate.py +603 -0
  179. rocket_engine/project/edit/__init__.py +5 -0
  180. rocket_engine/project/edit/cas.py +87 -0
  181. rocket_engine/project/edit/transactions.py +282 -0
  182. rocket_engine/project/errors.py +16 -0
  183. rocket_engine/project/frontend13.py +685 -0
  184. rocket_engine/project/geometry/__init__.py +23 -0
  185. rocket_engine/project/geometry/ops.py +451 -0
  186. rocket_engine/project/geometry/snap.py +103 -0
  187. rocket_engine/project/ip/__init__.py +1 -0
  188. rocket_engine/project/ip/blocks.py +195 -0
  189. rocket_engine/project/ip/entry.schema.json +78 -0
  190. rocket_engine/project/ip/lifecycle.py +402 -0
  191. rocket_engine/project/ip/verbs.py +123 -0
  192. rocket_engine/project/layers/__init__.py +6 -0
  193. rocket_engine/project/layers/bindings.py +115 -0
  194. rocket_engine/project/layers/catalog.py +228 -0
  195. rocket_engine/project/layers/presets.py +43 -0
  196. rocket_engine/project/ledger/__init__.py +1 -0
  197. rocket_engine/project/ledger/docs.py +167 -0
  198. rocket_engine/project/ledger/entities.py +176 -0
  199. rocket_engine/project/ledger/events.py +29 -0
  200. rocket_engine/project/ledger/runs.py +257 -0
  201. rocket_engine/project/ledger/shared.py +174 -0
  202. rocket_engine/project/ledger/snapshots.py +385 -0
  203. rocket_engine/project/ledger/wires.py +552 -0
  204. rocket_engine/project/process/__init__.py +14 -0
  205. rocket_engine/project/process/model.py +193 -0
  206. rocket_engine/project/process/recipes.py +121 -0
  207. rocket_engine/project/schematic/__init__.py +1 -0
  208. rocket_engine/project/schematic/hierarchy.py +312 -0
  209. rocket_engine/project/schematic/instances.py +393 -0
  210. rocket_engine/project/schematic/sheets.py +198 -0
  211. rocket_engine/project/service.py +2941 -0
  212. rocket_engine/project/symbols/__init__.py +1 -0
  213. rocket_engine/project/symbols/cells.py +324 -0
  214. rocket_engine/project/symbols/linkage.py +356 -0
  215. rocket_engine/project/symbols/symbols.py +352 -0
  216. rocket_engine/project/templates/__init__.py +303 -0
  217. rocket_engine/project/templates/errors.py +25 -0
  218. rocket_engine/project/testbench/__init__.py +49 -0
  219. rocket_engine/project/testbench/blocks.py +619 -0
  220. rocket_engine/project/testbench/templates.py +134 -0
  221. rocket_engine/stores/README.md +12 -0
  222. rocket_engine/stores/__init__.py +6 -0
  223. rocket_engine/stores/ecs.py +1508 -0
  224. rocket_engine/stores/layers.py +1023 -0
  225. rocket_engine/stores/settings.py +1188 -0
  226. rocket_engine/stores/wireplace.py +1324 -0
  227. rocket_engine/workflows/README.md +18 -0
  228. rocket_engine/workflows/__init__.py +53 -0
  229. rocket_engine/workflows/capability_plan.py +102 -0
  230. rocket_engine/workflows/completion.py +228 -0
  231. rocket_engine/workflows/connectgraph.py +734 -0
  232. rocket_engine/workflows/connectgraph_emit.py +336 -0
  233. rocket_engine/workflows/gates.py +144 -0
  234. rocket_engine/workflows/photonic_wire.py +76 -0
  235. rocket_engine/workflows/stages.py +324 -0
  236. rocket_engine/workflows/viewsync.py +399 -0
  237. rocket_engine-0.0.2b1.dist-info/METADATA +200 -0
  238. rocket_engine-0.0.2b1.dist-info/RECORD +241 -0
  239. rocket_engine-0.0.2b1.dist-info/WHEEL +4 -0
  240. rocket_engine-0.0.2b1.dist-info/entry_points.txt +2 -0
  241. rocket_engine-0.0.2b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2941 @@
1
+ """Durable project ledger (LEDGER-DURABLE/I30) + document CRUD (CRUD-API/I31).
2
+
3
+ Every engine-mediated mutation advances the revision and appends an
4
+ actor-attributed event; stale `expected_revision` fails with
5
+ `revision_conflict` instead of merging. With `engine_root` the
6
+ ledger persists under `ENGINE_ROOT/projects/<id>/` (`events.yaml`
7
+ + `ledger.yaml`, both schematized in `layout_schema.yaml`) and
8
+ survives restart; without it the service keeps the previous
9
+ in-memory behavior. Retention trims oldest-first past
10
+ `max_retained_events`; readers that demand contiguity from trimmed
11
+ history get `resync_required` and must re-read from the snapshot.
12
+
13
+ Documents (CRUD-API/I31) live beside the ledger: files under the
14
+ project dir for durable projects, an in-memory map otherwise.
15
+ `ledger.yaml`, `events.yaml`, `runs/`, and `snapshots/` are
16
+ engine-owned and rejected as document paths; everything else —
17
+ including the conventional `components/` and `scripts/` prefixes —
18
+ is a plain document addressed by its unix path. Single
19
+ writes/deletes and the atomic `apply_mutations` batch reuse the
20
+ same admission machinery as `submit` (idempotent request ids,
21
+ revision gate, one new revision, one event, atomic persist).
22
+
23
+ Snapshots (REWIND) capture every head: each accepted mutation
24
+ stores the managed bytes for its previous revision (when missing)
25
+ and its new revision under `snapshots/<revision>/` (an in-memory
26
+ map without an engine root). `rewind` restores the target bytes
27
+ under a new head revision with a `project_rewound` event — history
28
+ stays append-only, never rewritten. Linked caller trees stay
29
+ untouched: only engine-tracked bytes restore, and files outside
30
+ `.rocket/` are reported, never written.
31
+ """
32
+
33
+ import hashlib
34
+ import threading
35
+ import uuid
36
+ from pathlib import Path
37
+
38
+ from ruamel.yaml import YAML
39
+
40
+ from ..ops import editors as _editors
41
+ from ..ops.ports import ecs
42
+ from ..ops.ports import settings as settings_store
43
+ from ..ops.ports import wireplace
44
+ from ..interfaces.paths import join_contained
45
+ from ..interfaces.refs import pin_closure
46
+
47
+ from . import frontend13 as _frontend13
48
+ from .edit import cas as _cas
49
+ from .edit import transactions as _transactions
50
+ from .errors import ProjectError
51
+ from .geometry import ops as _geometry
52
+ from .ip.verbs import _IpMixin
53
+ from .layers import bindings as _layer_bindings
54
+ from .layers import catalog as _layer_catalog
55
+ from .process import model as _process
56
+ from .schematic import hierarchy as _hierarchy
57
+ from .schematic import instances as _instances
58
+ from .schematic import sheets as _sheets
59
+ from .symbols import cells as _cells
60
+ from .symbols import linkage as _linkage
61
+ from .symbols import symbols as _symbols
62
+ from .testbench import blocks as _testbench
63
+ from .ledger.docs import _DocMixin
64
+ from .ledger.entities import _EntityMixin
65
+ from .ledger.events import _EventMixin
66
+ from .ledger.runs import _RunMixin
67
+ from .ledger.snapshots import _SnapshotMixin
68
+ from .ledger.wires import _WireEntityStore as _WireEntityStore
69
+ from .ledger.wires import _WireMixin
70
+
71
+ from .ledger.shared import (
72
+ DEFAULT_MAX_RETAINED_EVENTS,
73
+ _EVENTS_FILENAME,
74
+ _LEDGER_FILENAME,
75
+ _MAX_DOC_BYTES,
76
+ _PROJECT_ID_RE,
77
+ _SETTINGS_DOC_PREFIX,
78
+ _advance_revision,
79
+ _atomic_write_text,
80
+ _canonical_doc_path,
81
+ _canonical_prefix,
82
+ _check_reserved_doc,
83
+ _dump_yaml,
84
+ )
85
+
86
+ class ProjectService(_DocMixin, _EntityMixin, _EventMixin, _IpMixin, _RunMixin, _SnapshotMixin, _WireMixin):
87
+ """Revision-gated ledger with idempotent requests and cursored events.
88
+
89
+ Args:
90
+ project_id: Project this ledger is bound to.
91
+ actor: Attribution recorded on every appended event
92
+ (human / rocket-fuel / system by convention; the engine
93
+ passes its own actor through verbatim).
94
+ current_revision: Starting revision for a fresh ledger (one
95
+ with no durable state yet).
96
+ engine_root: Engine root for durability; the ledger lives at
97
+ `projects/<project_id>/` beneath it (managed or linked
98
+ alike — the ledger is engine truth, never caller-tree
99
+ bytes). None keeps the in-memory behavior.
100
+ max_retained_events: Retention bound; only the newest events
101
+ are kept, trimmed oldest-first on every append.
102
+ linked_root: Caller tree for linked projects; rewind
103
+ reports files outside `.rocket/` and never writes to
104
+ this tree. None means a managed project with no
105
+ external report.
106
+
107
+ Caution: A stale expected_revision fails with revision_conflict
108
+ instead of merging — callers must re-read the snapshot and
109
+ retry at the new head. Reads and mutations reload durable
110
+ state first, so sequential writers through separate handles
111
+ stay ordered; truly concurrent writers still need an
112
+ external lock.
113
+ """
114
+
115
+ def __init__(self, project_id, actor, current_revision="p12",
116
+ engine_root=None, max_retained_events=None,
117
+ linked_root=None):
118
+ if max_retained_events is None:
119
+ max_retained_events = DEFAULT_MAX_RETAINED_EVENTS
120
+ if (
121
+ not isinstance(max_retained_events, int)
122
+ or isinstance(max_retained_events, bool)
123
+ or max_retained_events < 1
124
+ ):
125
+ raise ProjectError(
126
+ "invalid_ledger",
127
+ f"Bad max_retained_events: {max_retained_events!r}.",
128
+ )
129
+ self._project_id = project_id
130
+ self._actor = actor
131
+ self._current_revision = current_revision
132
+ self._max_retained_events = max_retained_events
133
+ # BATCH-PARALLEL: concurrent drains append run/replay events
134
+ # from worker threads — refresh/append/persist runs as one
135
+ # critical section so the durable log never interleaves.
136
+ self._ledger_lock = threading.RLock()
137
+ self._requests = {}
138
+ self._events = []
139
+ self._docs = {}
140
+ self._snapshots = {}
141
+ self._yaml = YAML(typ="safe")
142
+ self._dir = None
143
+ self._linked_root = (
144
+ Path(linked_root) if linked_root is not None else None
145
+ )
146
+ if engine_root is not None:
147
+ if not isinstance(project_id, str) or not _PROJECT_ID_RE.match(
148
+ project_id
149
+ ):
150
+ raise ProjectError(
151
+ "invalid_project", f"Bad project id: {project_id!r}."
152
+ )
153
+ self._dir = join_contained(engine_root, "projects", project_id)
154
+ self._load()
155
+
156
+ def submit(self, command_text, request_id, expected_revision=None):
157
+ """Admit one mutation: replay idempotent resubmits, else gate and log.
158
+
159
+ A resubmitted envelope returns its stored ack without
160
+ advancing; a new envelope gates its expected_revision, and
161
+ only an accepted mutation advances the revision and appends
162
+ the job_queued event.
163
+
164
+ Args:
165
+ command_text: YAML command mapping with an operation.
166
+ request_id: Client envelope id; resubmitting the identical
167
+ text returns the identical ack without advancing.
168
+ expected_revision: Revision the writer read; defaults to
169
+ the command's own expected_revision entry.
170
+
171
+ Returns:
172
+ Admission ack with run_id, snapshot_id, state queued,
173
+ the advanced current_revision, and the event cursor.
174
+
175
+ Raises:
176
+ ProjectError: invalid_yaml for an unparsable command,
177
+ revision_conflict for a stale expected_revision,
178
+ request_conflict for a reused id with new text.
179
+
180
+ Caution: Every accepted mutation advances the revision — a
181
+ second writer at the old head fails instead of merging.
182
+ """
183
+ try:
184
+ command = self._yaml.load(command_text)
185
+ except Exception as exc:
186
+ raise ProjectError("invalid_yaml", f"Cannot parse command: {exc}") from exc
187
+ if not isinstance(command, dict) or "operation" not in command:
188
+ raise ProjectError("invalid_yaml", "Command must be a mapping with an operation.")
189
+ self._refresh_durable()
190
+ prev = self._requests.get(request_id)
191
+ if prev is not None:
192
+ prev_text, prev_ack = prev
193
+ if prev_text != command_text:
194
+ raise ProjectError(
195
+ "request_conflict",
196
+ f"Request id reused with different input: {request_id}.",
197
+ )
198
+ return prev_ack
199
+ want = expected_revision if expected_revision is not None else command.get("expected_revision")
200
+ if want != self._current_revision:
201
+ raise ProjectError(
202
+ "revision_conflict",
203
+ f"Expected revision {self._current_revision}, got {want}.",
204
+ )
205
+ self._ensure_snapshot(want)
206
+ advanced = _advance_revision(want)
207
+ self._capture_snapshot(advanced)
208
+ digest = hashlib.sha1(
209
+ f"{self._project_id}:{want}:{command_text}".encode("utf-8")
210
+ ).hexdigest()[:12]
211
+ self._current_revision = advanced
212
+ ack = {
213
+ "request_id": request_id,
214
+ "project_id": self._project_id,
215
+ "run_id": uuid.uuid4().hex,
216
+ "snapshot_id": f"snapshot_{digest}",
217
+ "state": "queued",
218
+ "current_revision": self._current_revision,
219
+ }
220
+ self._requests[request_id] = (command_text, ack)
221
+ # Lazy: keeps the project import chain free of the execution
222
+ # package (REPLAY-SAFE content keys).
223
+ from ..execution.replay_safe import content_key_for
224
+ key_arguments = {
225
+ key: value
226
+ for key, value in (command.get("arguments") or {}).items()
227
+ if key != "expected_revision"
228
+ }
229
+ self._append_event(
230
+ "job_queued",
231
+ request_id,
232
+ {
233
+ "run_id": ack["run_id"],
234
+ "revision": self._current_revision,
235
+ "operation": command.get("operation"),
236
+ "content_key": content_key_for(
237
+ command.get("operation"), key_arguments
238
+ ),
239
+ },
240
+ )
241
+ ack["event_cursor"] = self._cursor()
242
+ self._persist_after_mutation()
243
+ return ack
244
+
245
+ def snapshot(self):
246
+ """Read the ledger head: project, revision, and event cursor.
247
+
248
+ Returns:
249
+ Snapshot mapping with project_id, current_revision, and
250
+ event_cursor.
251
+ """
252
+ self._refresh_durable()
253
+ return {
254
+ "project_id": self._project_id,
255
+ "current_revision": self._current_revision,
256
+ "event_cursor": self._cursor(),
257
+ }
258
+
259
+ def events_since(self, cursor, require_contiguous_from=None):
260
+ """Read retained events after a cursor, oldest first.
261
+
262
+ Args:
263
+ cursor: Only events with a higher seq are returned.
264
+ require_contiguous_from: Assert the reader holds
265
+ contiguous history from this seq; fails when the
266
+ history aged out of retention or runs ahead of the
267
+ live cursor.
268
+
269
+ Returns:
270
+ Retained events after the cursor, seq-ordered.
271
+
272
+ Raises:
273
+ ProjectError: resync_required when the contiguity demand
274
+ cannot be met — the reader must re-read the snapshot
275
+ plus the full retained log and reset its cursor.
276
+
277
+ Caution: The log is retention-bounded, not infinite — a
278
+ reader gone longer than the bound must resync, never
279
+ assume the gap is empty.
280
+ """
281
+ self._refresh_durable()
282
+ if (
283
+ require_contiguous_from is not None
284
+ and require_contiguous_from > self._cursor()
285
+ ):
286
+ raise ProjectError(
287
+ "resync_required", "Retained history is insufficient; resync."
288
+ )
289
+ if (
290
+ require_contiguous_from is not None
291
+ and require_contiguous_from >= 1
292
+ and self._events
293
+ and require_contiguous_from < self._retained_head()
294
+ ):
295
+ raise ProjectError(
296
+ "resync_required", "Retained history is insufficient; resync."
297
+ )
298
+ return [e for e in self._events if e["seq"] > cursor]
299
+
300
+ def list_projects(self):
301
+ """List projects under the engine root, bound always included.
302
+
303
+ Without an engine root the bound in-memory project is the
304
+ whole list; with one, every managed project dir plus the
305
+ bound project is reported with its head revision.
306
+
307
+ Returns:
308
+ Mapping with the bound project id and the sorted
309
+ project entries (project_id + current_revision).
310
+
311
+ Raises:
312
+ ProjectError: invalid_ledger when a sibling ledger
313
+ head is unreadable or corrupt.
314
+
315
+ Caution: A corrupt sibling ledger fails the whole list
316
+ loudly instead of being skipped.
317
+ """
318
+ self._refresh_durable()
319
+ if self._dir is None:
320
+ return {
321
+ "project_id": self._project_id,
322
+ "projects": [
323
+ {
324
+ "project_id": self._project_id,
325
+ "current_revision": self._current_revision,
326
+ }
327
+ ],
328
+ }
329
+ parent = self._dir.parent
330
+ try:
331
+ names = sorted(
332
+ p.name for p in parent.iterdir() if p.is_dir()
333
+ )
334
+ except FileNotFoundError:
335
+ names = []
336
+ seen = {n for n in names if _PROJECT_ID_RE.match(n)}
337
+ seen.add(self._project_id)
338
+ entries = []
339
+ for pid in sorted(seen):
340
+ if pid == self._project_id:
341
+ entries.append(
342
+ {
343
+ "project_id": pid,
344
+ "current_revision": self._current_revision,
345
+ }
346
+ )
347
+ continue
348
+ ledger_path = parent / pid / _LEDGER_FILENAME
349
+ try:
350
+ text = ledger_path.read_text(encoding="utf-8")
351
+ except FileNotFoundError:
352
+ entries.append(
353
+ {"project_id": pid, "current_revision": "p12"}
354
+ )
355
+ continue
356
+ except OSError as exc:
357
+ raise ProjectError(
358
+ "invalid_ledger",
359
+ f"Cannot read ledger for {pid}: {exc}.",
360
+ ) from exc
361
+ try:
362
+ data = self._yaml.load(text)
363
+ except Exception as exc:
364
+ raise ProjectError(
365
+ "invalid_ledger",
366
+ f"Cannot parse ledger for {pid}: {exc}.",
367
+ ) from exc
368
+ revision = (
369
+ data.get("current_revision")
370
+ if isinstance(data, dict)
371
+ else None
372
+ )
373
+ if not isinstance(revision, str) or not revision:
374
+ raise ProjectError(
375
+ "invalid_ledger",
376
+ f"Ledger head carries no revision: {pid}.",
377
+ )
378
+ entries.append(
379
+ {"project_id": pid, "current_revision": revision}
380
+ )
381
+ return {"project_id": self._project_id, "projects": entries}
382
+
383
+ def create_project(self, new_project_id, request_id, command_text):
384
+ """Create one managed project at revision p12.
385
+
386
+ The new ledger starts clean (no events); the response still
387
+ carries the approval-UI fields with a None prior revision.
388
+ Idempotent replays return the stored response instead of
389
+ failing as duplicates.
390
+
391
+ Args:
392
+ new_project_id: Slug for the project to create.
393
+ request_id: Client envelope id for idempotent replay.
394
+ command_text: YAML command text for identity comparison.
395
+
396
+ Returns:
397
+ Creation mapping with the new project id, p12 head,
398
+ actor, diff summary, revision transition, rewind
399
+ handle, empty events, and request id.
400
+
401
+ Raises:
402
+ ProjectError: invalid_command without an engine root;
403
+ invalid_project for a non-slug id; project_exists
404
+ for a duplicate; request_conflict for a reused id
405
+ with new text; invalid_ledger when the project
406
+ dir cannot be written.
407
+
408
+ Caution: Creating the bound project itself is a duplicate
409
+ once its dir exists — create-then-list, never assume.
410
+ """
411
+ self._refresh_durable()
412
+ replay = self._check_replay(request_id, command_text)
413
+ if replay is not None:
414
+ return replay
415
+ if self._dir is None:
416
+ raise ProjectError(
417
+ "invalid_command", "create_project needs engine_root."
418
+ )
419
+ if not isinstance(new_project_id, str) or not _PROJECT_ID_RE.match(
420
+ new_project_id
421
+ ):
422
+ raise ProjectError(
423
+ "invalid_project", f"Bad project id: {new_project_id!r}."
424
+ )
425
+ new_dir = self._dir.parent / new_project_id
426
+ if new_dir.exists():
427
+ raise ProjectError(
428
+ "project_exists",
429
+ f"Project already exists: {new_project_id}.",
430
+ )
431
+ try:
432
+ new_dir.mkdir(parents=True, exist_ok=False)
433
+ (new_dir / "runs").mkdir(parents=True, exist_ok=True)
434
+ except OSError as exc:
435
+ raise ProjectError(
436
+ "invalid_ledger",
437
+ f"Cannot create project {new_project_id}: {exc}.",
438
+ ) from exc
439
+ try:
440
+ _atomic_write_text(new_dir / _EVENTS_FILENAME, _dump_yaml([]))
441
+ _atomic_write_text(
442
+ new_dir / _LEDGER_FILENAME,
443
+ _dump_yaml(
444
+ {
445
+ "project_id": new_project_id,
446
+ "current_revision": "p12",
447
+ "requests": {},
448
+ }
449
+ ),
450
+ )
451
+ except OSError as exc:
452
+ raise ProjectError(
453
+ "invalid_ledger",
454
+ f"Cannot init project {new_project_id}: {exc}.",
455
+ ) from exc
456
+ response = {
457
+ "project_id": new_project_id,
458
+ "current_revision": "p12",
459
+ "previous_revision": None,
460
+ "revision_transition": {"from": None, "to": "p12"},
461
+ "actor": self._actor,
462
+ "diff_summary": f"created project {new_project_id}",
463
+ "rewind_handle": {
464
+ "project_id": new_project_id,
465
+ "revision": None,
466
+ "request_id": request_id,
467
+ },
468
+ "events": [],
469
+ "event_cursor": 0,
470
+ "request_id": request_id,
471
+ }
472
+ self._requests[request_id] = (command_text, response)
473
+ self._persist_after_mutation()
474
+ return response
475
+
476
+ def list_docs(self, prefix=None):
477
+ """List documents, optionally filtered by a path prefix.
478
+
479
+ Args:
480
+ prefix: Optional unix path prefix; empty matches
481
+ everything.
482
+
483
+ Returns:
484
+ Mapping with project id, head revision, event cursor,
485
+ and sorted document entries (path + bytes).
486
+
487
+ Raises:
488
+ ProjectError: invalid_path for a non-text prefix;
489
+ path_escape for an escaping prefix.
490
+ """
491
+ self._refresh_durable()
492
+ want = _canonical_prefix(prefix)
493
+ documents = []
494
+ for path, size in self._iter_doc_files():
495
+ if want and not (
496
+ path == want or path.startswith(want + "/")
497
+ ):
498
+ continue
499
+ documents.append({"path": path, "bytes": size})
500
+ return {
501
+ "project_id": self._project_id,
502
+ "current_revision": self._current_revision,
503
+ "event_cursor": self._cursor(),
504
+ "documents": documents,
505
+ }
506
+
507
+ def read_doc(self, path):
508
+ """Read one document as text at the current head.
509
+
510
+ Args:
511
+ path: Document path to read.
512
+
513
+ Returns:
514
+ Mapping with project id, head revision, canonical
515
+ path, content text, and byte size.
516
+
517
+ Raises:
518
+ ProjectError: invalid_path or path_escape for a bad
519
+ path; unknown_document for a missing document;
520
+ invalid_command when the path names a directory;
521
+ invalid_document when durable bytes are not text.
522
+ """
523
+ self._refresh_durable()
524
+ canonical = _canonical_doc_path(path)
525
+ _check_reserved_doc(canonical, path)
526
+ content = self._read_doc_text(canonical, path)
527
+ if content is None:
528
+ raise ProjectError(
529
+ "unknown_document", f"No such document: {path}."
530
+ )
531
+ return {
532
+ "project_id": self._project_id,
533
+ "current_revision": self._current_revision,
534
+ "path": canonical,
535
+ "content": content,
536
+ "bytes": len(content.encode("utf-8")),
537
+ }
538
+
539
+ def read_settings(self, kind):
540
+ """Read one settings kind at the current head.
541
+
542
+ Absent files read as defaults (absent-default-is-clean):
543
+ a fresh project needs no seeding write. Stored files
544
+ migrate in memory to the known head; future files fail
545
+ instead of serving truth this engine cannot validate.
546
+
547
+ Args:
548
+ kind: Settings kind (chip.manifest, design.rules,
549
+ net.classes, text.vars, lib.tables).
550
+
551
+ Returns:
552
+ Mapping with project id, head revision, kind, stored
553
+ flag, version, and the settings mapping.
554
+
555
+ Raises:
556
+ ProjectError: invalid_command for an unknown kind or
557
+ stored settings that break the kind schema;
558
+ invalid_document for stored settings that do not
559
+ parse; future_format for a file newer than the
560
+ head; missing_migration when no link covers the
561
+ stored version.
562
+ """
563
+ self._refresh_durable()
564
+ if kind not in settings_store.CURRENT_VERSIONS:
565
+ raise ProjectError(
566
+ "invalid_command", f"Unknown settings kind: {kind}."
567
+ )
568
+ canonical = _canonical_doc_path(f"{_SETTINGS_DOC_PREFIX}{kind}.yaml")
569
+ text = self._read_doc_text(canonical, canonical)
570
+ if text is None:
571
+ return {
572
+ "project_id": self._project_id,
573
+ "current_revision": self._current_revision,
574
+ "kind": kind,
575
+ "stored": False,
576
+ "version": settings_store.CURRENT_VERSIONS[kind],
577
+ "settings": settings_store.default_settings(kind),
578
+ }
579
+ try:
580
+ data = self._yaml.load(text)
581
+ except Exception as exc:
582
+ raise ProjectError(
583
+ "invalid_document",
584
+ f"Stored {kind} settings do not parse: {exc}.",
585
+ ) from exc
586
+ if not isinstance(data, dict):
587
+ raise ProjectError(
588
+ "invalid_document",
589
+ f"Stored {kind} settings must be a mapping.",
590
+ )
591
+ try:
592
+ migrated = settings_store.migrate(kind, data)
593
+ except settings_store.SettingsError as exc:
594
+ if exc.code in ("future_format", "missing_migration"):
595
+ raise ProjectError(exc.code, str(exc)) from exc
596
+ raise ProjectError(
597
+ "invalid_command",
598
+ f"Stored {kind} settings are invalid: {exc}.",
599
+ ) from exc
600
+ return {
601
+ "project_id": self._project_id,
602
+ "current_revision": self._current_revision,
603
+ "kind": kind,
604
+ "stored": True,
605
+ "version": migrated["meta"]["version"],
606
+ "settings": migrated,
607
+ }
608
+
609
+ def resolved_config(self, session_id=None, run_id=None):
610
+ """Bundle the resolved config for one session/run fetch.
611
+
612
+ All five kinds in one call — clients render from this
613
+ snapshot instead of per-event round-trips. Absent files
614
+ resolve to defaults; library refs stay unresolved here
615
+ (callers resolve lib.tables refs against their own roots).
616
+
617
+ Args:
618
+ session_id: Client session this snapshot serves.
619
+ run_id: Run this snapshot was resolved for, if any.
620
+
621
+ Returns:
622
+ Snapshot mapping with project id, head revision,
623
+ session/run ids, per-kind versions, all five settings
624
+ mappings, and library refs.
625
+
626
+ Raises:
627
+ ProjectError: future_format when any stored file is
628
+ newer than the head; missing_migration when no
629
+ link covers a stored version; invalid_document
630
+ for stored settings that do not parse.
631
+ """
632
+ self._refresh_durable()
633
+ by_kind = {}
634
+ for kind in settings_store.CURRENT_VERSIONS:
635
+ read = self.read_settings(kind)
636
+ by_kind[read["kind"]] = read["settings"]
637
+ snapshot = settings_store.snapshot_settings(
638
+ by_kind, session_id=session_id, run_id=run_id
639
+ )
640
+ snapshot["project_id"] = self._project_id
641
+ snapshot["current_revision"] = self._current_revision
642
+ return snapshot
643
+
644
+ def get_component(self, entity, component):
645
+ """Read one entity component payload at the current head.
646
+
647
+ Entities project over plain documents: the record lives at
648
+ entities/<entity>.json and reads through the same bytes
649
+ read_doc serves. Backfilled doc entities serve their
650
+ projected doc component here, so every world-snapshot
651
+ entry stays addressable.
652
+
653
+ Args:
654
+ entity: Entity id holding the component.
655
+ component: Component name to read.
656
+
657
+ Returns:
658
+ Mapping with project id, head revision, entity,
659
+ component, canonical address, storage path, the
660
+ component data, and the entity revision.
661
+
662
+ Raises:
663
+ ProjectError: invalid_entity or invalid_component for
664
+ malformed parts; unknown_entity for a missing
665
+ entity; unknown_component for a missing component;
666
+ invalid_document for a corrupt entity record.
667
+ """
668
+ self._refresh_durable()
669
+ try:
670
+ ecs.validate_entity_id(entity)
671
+ ecs.validate_component_name(component)
672
+ except ecs.EcsError as exc:
673
+ raise ProjectError(exc.code, str(exc)) from exc
674
+ if ecs.is_backfill_id(entity):
675
+ return self._read_backfill_component(entity, component)
676
+ path, decoded = self._read_entity_record(entity)
677
+ if component not in decoded["components"]:
678
+ raise ProjectError(
679
+ "unknown_component",
680
+ f"No component {component!r} on {entity}.",
681
+ )
682
+ return {
683
+ "address": ecs.format_address(entity, component),
684
+ "component": component,
685
+ "current_revision": self._current_revision,
686
+ "data": decoded["components"][component],
687
+ "entity": entity,
688
+ "entity_revision": self._entity_revision_of(decoded),
689
+ "path": path,
690
+ "project_id": self._project_id,
691
+ }
692
+
693
+ def components_of(self, entity):
694
+ """List the component names one entity carries.
695
+
696
+ Args:
697
+ entity: Entity id to inspect (backfilled ids serve
698
+ their projected doc component).
699
+
700
+ Returns:
701
+ Mapping with project id, head revision, entity,
702
+ storage path, and sorted component names.
703
+
704
+ Raises:
705
+ ProjectError: invalid_entity for a malformed id;
706
+ unknown_entity for a missing entity;
707
+ invalid_document for a corrupt entity record.
708
+ """
709
+ self._refresh_durable()
710
+ try:
711
+ ecs.validate_entity_id(entity)
712
+ except ecs.EcsError as exc:
713
+ raise ProjectError(exc.code, str(exc)) from exc
714
+ if ecs.is_backfill_id(entity):
715
+ record = self._find_backfill(entity)
716
+ return {
717
+ "components": sorted(record["components"]),
718
+ "current_revision": self._current_revision,
719
+ "entity": entity,
720
+ "path": record["components"][
721
+ ecs.BACKFILL_COMPONENT
722
+ ]["path"],
723
+ "project_id": self._project_id,
724
+ }
725
+ path, decoded = self._read_entity_record(entity)
726
+ return {
727
+ "components": sorted(decoded["components"]),
728
+ "current_revision": self._current_revision,
729
+ "entity": entity,
730
+ "path": path,
731
+ "project_id": self._project_id,
732
+ }
733
+
734
+ def entities_with(self, component, predicate=None):
735
+ """Filter entities to holders of one component.
736
+
737
+ Nets-of-block, pins-of-net, and open-problems become
738
+ filters here, not endpoints; backfilled docs answer the
739
+ doc component, so current project files stay filterable
740
+ without migration.
741
+
742
+ Args:
743
+ component: Component name holders must carry.
744
+ predicate: Exact-match mapping the component payload
745
+ must satisfy, or None for no filtering.
746
+
747
+ Returns:
748
+ Mapping with project id, head revision, component,
749
+ predicate, and sorted holder ids.
750
+
751
+ Raises:
752
+ ProjectError: invalid_component for a malformed
753
+ component name; invalid_predicate for a
754
+ non-mapping predicate; invalid_document for a
755
+ corrupt entity record.
756
+ """
757
+ self._refresh_durable()
758
+ try:
759
+ ecs.validate_component_name(component)
760
+ ecs.validate_predicate(predicate)
761
+ except ecs.EcsError as exc:
762
+ raise ProjectError(exc.code, str(exc)) from exc
763
+ records = self._entity_records()
764
+ if component == ecs.BACKFILL_COMPONENT:
765
+ records.extend(self._backfill_records())
766
+ holders = ecs.filter_entities(
767
+ records, component, predicate
768
+ )
769
+ return {
770
+ "component": component,
771
+ "current_revision": self._current_revision,
772
+ "entities": holders,
773
+ "predicate": predicate,
774
+ "project_id": self._project_id,
775
+ }
776
+
777
+ def world_snapshot(self, include_backfill=True):
778
+ """Bundle the miniplex-shaped world snapshot for mirrors.
779
+
780
+ A pure read: snapshotting never writes and never advances
781
+ the revision, so backfill cannot break any revision. Each
782
+ entry maps one-to-one onto a miniplex entity (id plus its
783
+ components), so mirrors load it directly.
784
+
785
+ Args:
786
+ include_backfill: Project current plain docs as
787
+ read-only virtual entities alongside engine
788
+ entities; engine entities only when False.
789
+
790
+ Returns:
791
+ Snapshot mapping with project id, head revision, and
792
+ entities sorted by id (each with id, kind, and
793
+ components).
794
+
795
+ Raises:
796
+ ProjectError: invalid_command for a non-bool flag;
797
+ invalid_document for a corrupt entity record.
798
+ """
799
+ self._refresh_durable()
800
+ if not isinstance(include_backfill, bool):
801
+ raise ProjectError(
802
+ "invalid_command",
803
+ "include_backfill must be a bool, "
804
+ f"got {include_backfill!r}.",
805
+ )
806
+ records = self._entity_records()
807
+ if include_backfill:
808
+ records.extend(self._backfill_records())
809
+ return ecs.build_world_snapshot(
810
+ self._project_id, self._current_revision, records
811
+ )
812
+
813
+ def write_doc(self, path, content, request_id, command_text,
814
+ expected_revision=None):
815
+ """Write one document: gate, store, advance, log.
816
+
817
+ A resubmitted envelope returns its stored response without
818
+ advancing; a new envelope gates its expected_revision, and
819
+ only an accepted write advances the revision and appends
820
+ the doc_written event.
821
+
822
+ Args:
823
+ path: Document path to create or overwrite.
824
+ content: UTF-8 text to store (1 MB limit).
825
+ request_id: Client envelope id; resubmitting the
826
+ identical text returns the identical response
827
+ without advancing.
828
+ command_text: YAML command text for identity comparison.
829
+ expected_revision: Revision the writer read.
830
+
831
+ Returns:
832
+ Mutation mapping with path, revision transition, new
833
+ head, actor, diff summary, rewind handle, the
834
+ appended event, cursor, and request id.
835
+
836
+ Raises:
837
+ ProjectError: request_conflict for a reused id with
838
+ new text; invalid_path or path_escape for a bad
839
+ path; invalid_command for non-text or oversize
840
+ content or a directory path; revision_conflict
841
+ for a stale head; invalid_ledger when the durable
842
+ write fails.
843
+
844
+ Caution: Every accepted write advances the revision — a
845
+ second writer at the old head fails instead of
846
+ merging.
847
+ """
848
+ self._refresh_durable()
849
+ replay = self._check_replay(request_id, command_text)
850
+ if replay is not None:
851
+ return replay
852
+ canonical = _canonical_doc_path(path)
853
+ _check_reserved_doc(canonical, path)
854
+ if not isinstance(content, str):
855
+ raise ProjectError(
856
+ "invalid_command",
857
+ f"Write needs text content for document {path}.",
858
+ )
859
+ if len(content.encode("utf-8")) > _MAX_DOC_BYTES:
860
+ raise ProjectError(
861
+ "invalid_command",
862
+ f"Document exceeds the size limit: {path}.",
863
+ )
864
+ self._gate_revision(expected_revision)
865
+ previous = self._current_revision
866
+ self._ensure_snapshot(previous)
867
+ old = self._read_doc_text(canonical, path)
868
+ self._write_doc_text(canonical, content, path)
869
+ advanced = _advance_revision(previous)
870
+ try:
871
+ self._capture_snapshot(advanced)
872
+ except ProjectError:
873
+ try:
874
+ if old is None:
875
+ self._delete_doc_path(canonical, path)
876
+ else:
877
+ self._write_doc_text(canonical, old, path)
878
+ except Exception:
879
+ pass
880
+ raise
881
+ self._current_revision = advanced
882
+ new_bytes = len(content.encode("utf-8"))
883
+ if old is None:
884
+ diff = f"created {canonical} ({new_bytes} bytes)"
885
+ else:
886
+ diff = (
887
+ f"updated {canonical} "
888
+ f"({len(old.encode('utf-8'))} -> {new_bytes} bytes)"
889
+ )
890
+ record = self._append_event(
891
+ "doc_written",
892
+ request_id,
893
+ {
894
+ "revision": self._current_revision,
895
+ "operation": "write_doc",
896
+ "path": canonical,
897
+ "bytes": new_bytes,
898
+ "diff_summary": diff,
899
+ },
900
+ )
901
+ response = {
902
+ "project_id": self._project_id,
903
+ "path": canonical,
904
+ "previous_revision": previous,
905
+ "current_revision": self._current_revision,
906
+ "revision_transition": {
907
+ "from": previous,
908
+ "to": self._current_revision,
909
+ },
910
+ "actor": self._actor,
911
+ "diff_summary": diff,
912
+ "rewind_handle": {
913
+ "project_id": self._project_id,
914
+ "revision": previous,
915
+ "request_id": request_id,
916
+ },
917
+ "events": [record],
918
+ "event_cursor": self._cursor(),
919
+ "request_id": request_id,
920
+ }
921
+ self._requests[request_id] = (command_text, response)
922
+ self._persist_after_mutation()
923
+ return response
924
+
925
+ def write_settings(self, kind, settings, request_id, command_text,
926
+ expected_revision=None):
927
+ """Write one settings kind: validate, gate, merge, log.
928
+
929
+ Full replacement of the kind's truth, persisted as the
930
+ settings/<kind>.yaml document through the same admission
931
+ machinery as write_doc (idempotent request ids, revision
932
+ gate, one new revision, one event, atomic persist, rewind
933
+ snapshots). The write merges onto the stored bytes so
934
+ unknown keys survive; data maps replace wholesale, so
935
+ text.vars deletes propagate. Unchanged bytes skip the
936
+ write: no new revision, no event.
937
+
938
+ A resubmitted envelope returns its stored response without
939
+ advancing.
940
+
941
+ Args:
942
+ kind: Settings kind (chip.manifest, design.rules,
943
+ net.classes, text.vars, lib.tables).
944
+ settings: Complete settings mapping at the head
945
+ version.
946
+ request_id: Client envelope id; resubmitting the
947
+ identical text returns the identical response
948
+ without advancing.
949
+ command_text: YAML command text for identity comparison.
950
+ expected_revision: Revision the writer read.
951
+
952
+ Returns:
953
+ Mutation mapping with path, revision transition, new
954
+ head, actor, diff summary, rewind handle, the
955
+ appended event, cursor, request id, written flag, and
956
+ settings version.
957
+
958
+ Raises:
959
+ ProjectError: request_conflict for a reused id with
960
+ new text; invalid_command for an unknown kind or
961
+ settings that break the kind schema;
962
+ revision_conflict for a stale head; future_format
963
+ when the stored file is newer than the head;
964
+ invalid_document for stored settings that do not
965
+ parse; invalid_ledger when the durable write fails.
966
+
967
+ Caution: Every accepted write advances the revision — a
968
+ second writer at the old head fails instead of
969
+ merging — and text.vars writes delete vars the new
970
+ map omits.
971
+ """
972
+ self._refresh_durable()
973
+ replay = self._check_replay(request_id, command_text)
974
+ if replay is not None:
975
+ return replay
976
+ try:
977
+ settings_store.validate_settings(kind, settings)
978
+ except settings_store.SettingsError as exc:
979
+ raise ProjectError(
980
+ "invalid_command", f"Bad {kind} settings: {exc}."
981
+ ) from exc
982
+ self._gate_revision(expected_revision)
983
+ canonical = _canonical_doc_path(f"{_SETTINGS_DOC_PREFIX}{kind}.yaml")
984
+ old_text = self._read_doc_text(canonical, canonical)
985
+ old_data = None
986
+ if old_text is not None:
987
+ try:
988
+ old_data = self._yaml.load(old_text)
989
+ except Exception as exc:
990
+ raise ProjectError(
991
+ "invalid_document",
992
+ f"Stored {kind} settings do not parse: {exc}.",
993
+ ) from exc
994
+ if not isinstance(old_data, dict):
995
+ raise ProjectError(
996
+ "invalid_document",
997
+ f"Stored {kind} settings must be a mapping.",
998
+ )
999
+ old_version = settings_store.settings_version(kind, old_data)
1000
+ if old_version > settings_store.settings_version(kind, settings):
1001
+ raise ProjectError(
1002
+ "future_format",
1003
+ f"Stored {kind} settings are newer than this "
1004
+ "engine knows; refusing autosave.",
1005
+ )
1006
+ merged = settings_store.merge_settings(kind, old_data, settings)
1007
+ text = settings_store.dump_settings_text(merged)
1008
+ version = settings_store.settings_version(kind, merged)
1009
+ if old_text is not None and text == old_text:
1010
+ response = {
1011
+ "project_id": self._project_id,
1012
+ "path": canonical,
1013
+ "previous_revision": self._current_revision,
1014
+ "current_revision": self._current_revision,
1015
+ "revision_transition": {
1016
+ "from": self._current_revision,
1017
+ "to": self._current_revision,
1018
+ },
1019
+ "actor": self._actor,
1020
+ "diff_summary": f"unchanged {canonical}",
1021
+ "rewind_handle": {
1022
+ "project_id": self._project_id,
1023
+ "revision": self._current_revision,
1024
+ "request_id": request_id,
1025
+ },
1026
+ "events": [],
1027
+ "event_cursor": self._cursor(),
1028
+ "request_id": request_id,
1029
+ "written": False,
1030
+ "settings_version": version,
1031
+ }
1032
+ self._requests[request_id] = (command_text, response)
1033
+ self._persist_after_mutation()
1034
+ return response
1035
+ response = self.write_doc(
1036
+ canonical, text, request_id, command_text,
1037
+ expected_revision=expected_revision,
1038
+ )
1039
+ response["written"] = True
1040
+ response["settings_version"] = version
1041
+ return response
1042
+
1043
+ def create_entity(self, request_id, command_text, kind="",
1044
+ components=None, expected_revision=None):
1045
+ """Create one entity with an engine-minted id.
1046
+
1047
+ The id is minted, never supplied: there is no id argument,
1048
+ so callers cannot collide with, guess, or squat on engine
1049
+ identity. The record persists as entities/<id>.json through
1050
+ the same admission machinery as write_doc (idempotent
1051
+ request ids, revision gate, one new revision, one event,
1052
+ atomic persist, rewind snapshots).
1053
+
1054
+ A resubmitted envelope returns its stored response without
1055
+ advancing.
1056
+
1057
+ Args:
1058
+ request_id: Client envelope id; resubmitting the
1059
+ identical text returns the identical response
1060
+ without advancing.
1061
+ command_text: YAML command text for identity comparison.
1062
+ kind: Kind label carried beside the components.
1063
+ components: Optional initial component mapping.
1064
+ expected_revision: Accepted and ignored (ENTITY-CAS):
1065
+ minted ids cannot collide, so creates never
1066
+ conflict — concurrent creates always land with
1067
+ distinct ids.
1068
+
1069
+ Returns:
1070
+ Mutation mapping with the minted entity, kind, storage
1071
+ path, revision transition, new head, actor, diff
1072
+ summary, rewind handle, the entity_created event,
1073
+ cursor, request id, initial component names, and the
1074
+ entity_revision (1).
1075
+
1076
+ Raises:
1077
+ ProjectError: request_conflict for a reused id with
1078
+ new text; invalid_kind, invalid_component, or
1079
+ invalid_data for malformed parts;
1080
+ invalid_ledger when the durable write fails.
1081
+
1082
+ Caution: Every accepted create advances the revision, but
1083
+ never fails on a stale head — identity is minted, so
1084
+ there is nothing to merge or conflict with.
1085
+ """
1086
+ self._refresh_durable()
1087
+ replay = self._check_replay(request_id, command_text)
1088
+ if replay is not None:
1089
+ return replay
1090
+ try:
1091
+ ecs.validate_kind(kind)
1092
+ if components is not None and not isinstance(
1093
+ components, dict
1094
+ ):
1095
+ raise ecs.EcsError(
1096
+ "invalid_data",
1097
+ "Initial components must be a mapping.",
1098
+ )
1099
+ initial = dict(components or {})
1100
+ for name, data in initial.items():
1101
+ ecs.validate_component_name(name)
1102
+ ecs.validate_component_data(data)
1103
+ except ecs.EcsError as exc:
1104
+ raise ProjectError(exc.code, str(exc)) from exc
1105
+ # No head gate: minted identity cannot collide, so concurrent
1106
+ # creates never conflict (ENTITY-CAS).
1107
+ previous = self._current_revision
1108
+ self._ensure_snapshot(previous)
1109
+ taken = set()
1110
+ for rel, _size in self._iter_doc_files():
1111
+ found = ecs.entity_from_doc_path(rel)
1112
+ if found is not None:
1113
+ taken.add(found)
1114
+ entity = ecs.mint_entity_id(existing=taken)
1115
+ path = ecs.entity_doc_path(entity)
1116
+ self._store_entity_record(path, entity, kind, initial, 1)
1117
+ advanced = _advance_revision(previous)
1118
+ try:
1119
+ self._capture_snapshot(advanced)
1120
+ except ProjectError:
1121
+ try:
1122
+ self._delete_doc_path(path, path)
1123
+ except Exception:
1124
+ pass
1125
+ raise
1126
+ self._current_revision = advanced
1127
+ diff = f"created entity {entity} ({path})"
1128
+ record = self._append_event(
1129
+ "entity_created",
1130
+ request_id,
1131
+ {
1132
+ "components": sorted(initial),
1133
+ "entity": entity,
1134
+ "kind": kind,
1135
+ "revision": self._current_revision,
1136
+ },
1137
+ )
1138
+ response = self._ecs_response(
1139
+ request_id, previous, diff, record,
1140
+ {
1141
+ "components": sorted(initial),
1142
+ "entity": entity,
1143
+ "entity_revision": 1,
1144
+ "kind": kind,
1145
+ "path": path,
1146
+ },
1147
+ )
1148
+ self._requests[request_id] = (command_text, response)
1149
+ self._persist_after_mutation()
1150
+ return response
1151
+
1152
+ def set_component(self, entity, component, data, request_id,
1153
+ command_text, expected_revision=None,
1154
+ expected_entity_revision=None):
1155
+ """Upsert one component payload (create or overwrite).
1156
+
1157
+ Persists through the entity record at entities/<entity>.json
1158
+ with the same admission machinery as write_doc, and appends
1159
+ component_changed with entity, component, and revision.
1160
+
1161
+ A resubmitted envelope returns its stored response without
1162
+ advancing.
1163
+
1164
+ Args:
1165
+ entity: Entity id holding the component.
1166
+ component: Component name to write.
1167
+ data: JSON payload to store.
1168
+ request_id: Client envelope id; resubmitting the
1169
+ identical text returns the identical response
1170
+ without advancing.
1171
+ command_text: YAML command text for identity comparison.
1172
+ expected_revision: Revision the writer read.
1173
+ expected_entity_revision: Entity revision the writer
1174
+ read (ENTITY-CAS). Naming a current one skips the
1175
+ global head gate even beside a stale head, so
1176
+ writers on different entities never conflict; a
1177
+ stale entity revision fails with revision_conflict.
1178
+
1179
+ Returns:
1180
+ Mutation mapping with entity, component, canonical
1181
+ address, storage path, revision transition, new head,
1182
+ actor, diff summary, rewind handle, the
1183
+ component_changed event, cursor, request id, and the
1184
+ bumped entity_revision.
1185
+
1186
+ Raises:
1187
+ ProjectError: request_conflict for a reused id with
1188
+ new text; invalid_entity, invalid_component, or
1189
+ invalid_data for malformed parts; unknown_entity
1190
+ for a missing entity; backfill_readonly for a
1191
+ backfilled id; invalid_document for a corrupt
1192
+ entity record; revision_conflict for a stale head
1193
+ or a stale entity revision; invalid_ledger when
1194
+ the durable write fails.
1195
+
1196
+ Caution: Every accepted write advances the revision — a
1197
+ second writer at the old head fails instead of
1198
+ merging, unless it names a current entity
1199
+ revision.
1200
+ """
1201
+ self._refresh_durable()
1202
+ replay = self._check_replay(request_id, command_text)
1203
+ if replay is not None:
1204
+ return replay
1205
+ try:
1206
+ ecs.validate_entity_id(entity)
1207
+ ecs.validate_component_name(component)
1208
+ ecs.validate_component_data(data)
1209
+ except ecs.EcsError as exc:
1210
+ raise ProjectError(exc.code, str(exc)) from exc
1211
+ if ecs.is_backfill_id(entity):
1212
+ raise ProjectError(
1213
+ "backfill_readonly",
1214
+ f"Backfilled entity is read-only: {entity}.",
1215
+ )
1216
+ path, decoded = self._read_entity_record(entity)
1217
+ live = self._gate_entity_revision(
1218
+ entity, decoded, expected_entity_revision
1219
+ )
1220
+ self._gate_entity_write(expected_revision, expected_entity_revision)
1221
+ previous = self._current_revision
1222
+ self._ensure_snapshot(previous)
1223
+ existed = component in decoded["components"]
1224
+ merged = dict(decoded["components"])
1225
+ merged[component] = data
1226
+ bumped = live + 1
1227
+ old_text = self._read_doc_text(path, path)
1228
+ self._store_entity_record(
1229
+ path, entity, decoded["kind"], merged, bumped
1230
+ )
1231
+ advanced = _advance_revision(previous)
1232
+ try:
1233
+ self._capture_snapshot(advanced)
1234
+ except ProjectError:
1235
+ try:
1236
+ self._write_doc_text(path, old_text, path)
1237
+ except Exception:
1238
+ pass
1239
+ raise
1240
+ self._current_revision = advanced
1241
+ verb = "updated" if existed else "set"
1242
+ address = ecs.format_address(entity, component)
1243
+ diff = f"{verb} {address} ({path})"
1244
+ record = self._append_event(
1245
+ "component_changed",
1246
+ request_id,
1247
+ ecs.component_changed_data(
1248
+ entity, component, self._current_revision,
1249
+ entity_revision=bumped,
1250
+ ),
1251
+ )
1252
+ response = self._ecs_response(
1253
+ request_id, previous, diff, record,
1254
+ {
1255
+ "address": address,
1256
+ "component": component,
1257
+ "entity": entity,
1258
+ "entity_revision": bumped,
1259
+ "path": path,
1260
+ },
1261
+ )
1262
+ self._requests[request_id] = (command_text, response)
1263
+ self._persist_after_mutation()
1264
+ return response
1265
+
1266
+ def attach_component(self, entity, component, data, request_id,
1267
+ command_text, expected_revision=None,
1268
+ expected_entity_revision=None):
1269
+ """Attach one new component (refuses overwrites).
1270
+
1271
+ Like set_component, but a component already living on the
1272
+ entity fails instead of overwriting — attach means add.
1273
+
1274
+ A resubmitted envelope returns its stored response without
1275
+ advancing.
1276
+
1277
+ Args:
1278
+ entity: Entity id holding the component.
1279
+ component: Component name to attach.
1280
+ data: JSON payload to store.
1281
+ request_id: Client envelope id; resubmitting the
1282
+ identical text returns the identical response
1283
+ without advancing.
1284
+ command_text: YAML command text for identity comparison.
1285
+ expected_revision: Revision the writer read.
1286
+ expected_entity_revision: Entity revision the writer
1287
+ read (ENTITY-CAS); naming a current one skips the
1288
+ global head gate even beside a stale head.
1289
+
1290
+ Returns:
1291
+ Mutation mapping with entity, component, canonical
1292
+ address, storage path, revision transition, new head,
1293
+ actor, diff summary, rewind handle, the
1294
+ component_changed event, cursor, request id, and the
1295
+ bumped entity_revision.
1296
+
1297
+ Raises:
1298
+ ProjectError: component_exists when the component
1299
+ already lives on the entity; otherwise like
1300
+ set_component.
1301
+
1302
+ Caution: Every accepted attach advances the revision — a
1303
+ second writer at the old head fails instead of
1304
+ merging, unless it names a current entity
1305
+ revision.
1306
+ """
1307
+ self._refresh_durable()
1308
+ replay = self._check_replay(request_id, command_text)
1309
+ if replay is not None:
1310
+ return replay
1311
+ try:
1312
+ ecs.validate_entity_id(entity)
1313
+ ecs.validate_component_name(component)
1314
+ ecs.validate_component_data(data)
1315
+ except ecs.EcsError as exc:
1316
+ raise ProjectError(exc.code, str(exc)) from exc
1317
+ if ecs.is_backfill_id(entity):
1318
+ raise ProjectError(
1319
+ "backfill_readonly",
1320
+ f"Backfilled entity is read-only: {entity}.",
1321
+ )
1322
+ path, decoded = self._read_entity_record(entity)
1323
+ if component in decoded["components"]:
1324
+ raise ProjectError(
1325
+ "component_exists",
1326
+ "Component already attached: "
1327
+ f"{ecs.format_address(entity, component)}.",
1328
+ )
1329
+ live = self._gate_entity_revision(
1330
+ entity, decoded, expected_entity_revision
1331
+ )
1332
+ self._gate_entity_write(expected_revision, expected_entity_revision)
1333
+ previous = self._current_revision
1334
+ self._ensure_snapshot(previous)
1335
+ merged = dict(decoded["components"])
1336
+ merged[component] = data
1337
+ bumped = live + 1
1338
+ old_text = self._read_doc_text(path, path)
1339
+ self._store_entity_record(
1340
+ path, entity, decoded["kind"], merged, bumped
1341
+ )
1342
+ advanced = _advance_revision(previous)
1343
+ try:
1344
+ self._capture_snapshot(advanced)
1345
+ except ProjectError:
1346
+ try:
1347
+ self._write_doc_text(path, old_text, path)
1348
+ except Exception:
1349
+ pass
1350
+ raise
1351
+ self._current_revision = advanced
1352
+ address = ecs.format_address(entity, component)
1353
+ diff = f"attached {address} ({path})"
1354
+ record = self._append_event(
1355
+ "component_changed",
1356
+ request_id,
1357
+ ecs.component_changed_data(
1358
+ entity, component, self._current_revision,
1359
+ entity_revision=bumped,
1360
+ ),
1361
+ )
1362
+ response = self._ecs_response(
1363
+ request_id, previous, diff, record,
1364
+ {
1365
+ "address": address,
1366
+ "component": component,
1367
+ "entity": entity,
1368
+ "entity_revision": bumped,
1369
+ "path": path,
1370
+ },
1371
+ )
1372
+ self._requests[request_id] = (command_text, response)
1373
+ self._persist_after_mutation()
1374
+ return response
1375
+
1376
+ def detach_component(self, entity, component, request_id,
1377
+ command_text, expected_revision=None,
1378
+ expected_entity_revision=None):
1379
+ """Detach one component from its entity.
1380
+
1381
+ Persists through the entity record with the same admission
1382
+ machinery as write_doc; the component_changed event carries
1383
+ detached True.
1384
+
1385
+ A resubmitted envelope returns its stored response without
1386
+ advancing.
1387
+
1388
+ Args:
1389
+ entity: Entity id holding the component.
1390
+ component: Component name to remove.
1391
+ request_id: Client envelope id; resubmitting the
1392
+ identical text returns the identical response
1393
+ without advancing.
1394
+ command_text: YAML command text for identity comparison.
1395
+ expected_revision: Revision the writer read.
1396
+ expected_entity_revision: Entity revision the writer
1397
+ read (ENTITY-CAS).
1398
+
1399
+ Returns:
1400
+ Mutation mapping with entity, component, canonical
1401
+ address, storage path, revision transition, new head,
1402
+ actor, diff summary, rewind handle, the
1403
+ component_changed event, cursor, request id, and the
1404
+ bumped entity_revision.
1405
+
1406
+ Raises:
1407
+ ProjectError: unknown_component for a missing
1408
+ component; otherwise like set_component without
1409
+ the data errors.
1410
+
1411
+ Caution: Every accepted detach advances the revision — a
1412
+ second writer at the old head fails instead of
1413
+ merging, unless it names a current entity
1414
+ revision.
1415
+ """
1416
+ self._refresh_durable()
1417
+ replay = self._check_replay(request_id, command_text)
1418
+ if replay is not None:
1419
+ return replay
1420
+ try:
1421
+ ecs.validate_entity_id(entity)
1422
+ ecs.validate_component_name(component)
1423
+ except ecs.EcsError as exc:
1424
+ raise ProjectError(exc.code, str(exc)) from exc
1425
+ if ecs.is_backfill_id(entity):
1426
+ raise ProjectError(
1427
+ "backfill_readonly",
1428
+ f"Backfilled entity is read-only: {entity}.",
1429
+ )
1430
+ path, decoded = self._read_entity_record(entity)
1431
+ if component not in decoded["components"]:
1432
+ raise ProjectError(
1433
+ "unknown_component",
1434
+ f"No component {component!r} on {entity}.",
1435
+ )
1436
+ live = self._gate_entity_revision(
1437
+ entity, decoded, expected_entity_revision
1438
+ )
1439
+ self._gate_entity_write(expected_revision, expected_entity_revision)
1440
+ previous = self._current_revision
1441
+ self._ensure_snapshot(previous)
1442
+ merged = dict(decoded["components"])
1443
+ del merged[component]
1444
+ bumped = live + 1
1445
+ old_text = self._read_doc_text(path, path)
1446
+ self._store_entity_record(
1447
+ path, entity, decoded["kind"], merged, bumped
1448
+ )
1449
+ advanced = _advance_revision(previous)
1450
+ try:
1451
+ self._capture_snapshot(advanced)
1452
+ except ProjectError:
1453
+ try:
1454
+ self._write_doc_text(path, old_text, path)
1455
+ except Exception:
1456
+ pass
1457
+ raise
1458
+ self._current_revision = advanced
1459
+ address = ecs.format_address(entity, component)
1460
+ diff = f"detached {address} ({path})"
1461
+ record = self._append_event(
1462
+ "component_changed",
1463
+ request_id,
1464
+ ecs.component_changed_data(
1465
+ entity, component, self._current_revision,
1466
+ detached=True, entity_revision=bumped,
1467
+ ),
1468
+ )
1469
+ response = self._ecs_response(
1470
+ request_id, previous, diff, record,
1471
+ {
1472
+ "address": address,
1473
+ "component": component,
1474
+ "entity": entity,
1475
+ "entity_revision": bumped,
1476
+ "path": path,
1477
+ },
1478
+ )
1479
+ self._requests[request_id] = (command_text, response)
1480
+ self._persist_after_mutation()
1481
+ return response
1482
+
1483
+ def delete_entity(self, entity, request_id, command_text,
1484
+ expected_revision=None,
1485
+ expected_entity_revision=None):
1486
+ """Delete one entity and its projected record.
1487
+
1488
+ Removes entities/<entity>.json through the same admission
1489
+ machinery as delete_doc, and appends entity_deleted.
1490
+
1491
+ A resubmitted envelope returns its stored response without
1492
+ advancing.
1493
+
1494
+ Args:
1495
+ entity: Entity id to delete.
1496
+ request_id: Client envelope id; resubmitting the
1497
+ identical text returns the identical response
1498
+ without advancing.
1499
+ command_text: YAML command text for identity comparison.
1500
+ expected_revision: Revision the writer read.
1501
+ expected_entity_revision: Entity revision the writer
1502
+ read (ENTITY-CAS).
1503
+
1504
+ Returns:
1505
+ Mutation mapping with entity, storage path, revision
1506
+ transition, new head, actor, diff summary, rewind
1507
+ handle, the entity_deleted event, cursor, request id,
1508
+ and the deleted entity_revision.
1509
+
1510
+ Raises:
1511
+ ProjectError: request_conflict for a reused id with
1512
+ new text; invalid_entity for a malformed id;
1513
+ unknown_entity for a missing entity;
1514
+ backfill_readonly for a backfilled id;
1515
+ invalid_document for a corrupt entity record;
1516
+ revision_conflict for a stale head or a stale
1517
+ entity revision; invalid_ledger
1518
+ when the durable delete fails.
1519
+
1520
+ Caution: Deleting advances the revision like any mutation
1521
+ — stale writers fail instead of merging, unless they
1522
+ name a current entity revision.
1523
+ """
1524
+ self._refresh_durable()
1525
+ replay = self._check_replay(request_id, command_text)
1526
+ if replay is not None:
1527
+ return replay
1528
+ try:
1529
+ ecs.validate_entity_id(entity)
1530
+ except ecs.EcsError as exc:
1531
+ raise ProjectError(exc.code, str(exc)) from exc
1532
+ if ecs.is_backfill_id(entity):
1533
+ raise ProjectError(
1534
+ "backfill_readonly",
1535
+ f"Backfilled entity is read-only: {entity}.",
1536
+ )
1537
+ path, decoded = self._read_entity_record(entity)
1538
+ live = self._gate_entity_revision(
1539
+ entity, decoded, expected_entity_revision
1540
+ )
1541
+ self._gate_entity_write(expected_revision, expected_entity_revision)
1542
+ previous = self._current_revision
1543
+ self._ensure_snapshot(previous)
1544
+ old_text = self._read_doc_text(path, path)
1545
+ self._delete_doc_path(path, path)
1546
+ advanced = _advance_revision(previous)
1547
+ try:
1548
+ self._capture_snapshot(advanced)
1549
+ except ProjectError:
1550
+ try:
1551
+ self._write_doc_text(path, old_text, path)
1552
+ except Exception:
1553
+ pass
1554
+ raise
1555
+ self._current_revision = advanced
1556
+ diff = f"deleted entity {entity} ({path})"
1557
+ record = self._append_event(
1558
+ "entity_deleted",
1559
+ request_id,
1560
+ {"entity": entity, "revision": self._current_revision},
1561
+ )
1562
+ response = self._ecs_response(
1563
+ request_id, previous, diff, record,
1564
+ {"entity": entity, "entity_revision": live, "path": path},
1565
+ )
1566
+ self._requests[request_id] = (command_text, response)
1567
+ self._persist_after_mutation()
1568
+ return response
1569
+
1570
+ def delete_doc(self, path, request_id, command_text,
1571
+ expected_revision=None):
1572
+ """Delete one document: gate, remove, advance, log.
1573
+
1574
+ Args:
1575
+ path: Document path to delete.
1576
+ request_id: Client envelope id for idempotent replay.
1577
+ command_text: YAML command text for identity comparison.
1578
+ expected_revision: Revision the writer read.
1579
+
1580
+ Returns:
1581
+ Mutation mapping with path, revision transition, new
1582
+ head, actor, diff summary, rewind handle, the
1583
+ appended event, cursor, and request id.
1584
+
1585
+ Raises:
1586
+ ProjectError: request_conflict for a reused id with
1587
+ new text; invalid_path or path_escape for a bad
1588
+ path; unknown_document for a missing document;
1589
+ invalid_command when the path names a directory;
1590
+ revision_conflict for a stale head; invalid_ledger
1591
+ when the durable delete fails.
1592
+
1593
+ Caution: Deleting advances the revision like any mutation
1594
+ — stale writers fail instead of merging.
1595
+ """
1596
+ self._refresh_durable()
1597
+ replay = self._check_replay(request_id, command_text)
1598
+ if replay is not None:
1599
+ return replay
1600
+ canonical = _canonical_doc_path(path)
1601
+ _check_reserved_doc(canonical, path)
1602
+ old = self._read_doc_text(canonical, path)
1603
+ if old is None:
1604
+ raise ProjectError(
1605
+ "unknown_document", f"No such document: {path}."
1606
+ )
1607
+ self._gate_revision(expected_revision)
1608
+ previous = self._current_revision
1609
+ self._ensure_snapshot(previous)
1610
+ self._delete_doc_path(canonical, path)
1611
+ advanced = _advance_revision(previous)
1612
+ try:
1613
+ self._capture_snapshot(advanced)
1614
+ except ProjectError:
1615
+ try:
1616
+ self._write_doc_text(canonical, old, path)
1617
+ except Exception:
1618
+ pass
1619
+ raise
1620
+ self._current_revision = advanced
1621
+ diff = f"deleted {canonical} ({len(old.encode('utf-8'))} bytes)"
1622
+ record = self._append_event(
1623
+ "doc_deleted",
1624
+ request_id,
1625
+ {
1626
+ "revision": self._current_revision,
1627
+ "operation": "delete_doc",
1628
+ "path": canonical,
1629
+ "bytes": len(old.encode("utf-8")),
1630
+ "diff_summary": diff,
1631
+ },
1632
+ )
1633
+ response = {
1634
+ "project_id": self._project_id,
1635
+ "path": canonical,
1636
+ "previous_revision": previous,
1637
+ "current_revision": self._current_revision,
1638
+ "revision_transition": {
1639
+ "from": previous,
1640
+ "to": self._current_revision,
1641
+ },
1642
+ "actor": self._actor,
1643
+ "diff_summary": diff,
1644
+ "rewind_handle": {
1645
+ "project_id": self._project_id,
1646
+ "revision": previous,
1647
+ "request_id": request_id,
1648
+ },
1649
+ "events": [record],
1650
+ "event_cursor": self._cursor(),
1651
+ "request_id": request_id,
1652
+ }
1653
+ self._requests[request_id] = (command_text, response)
1654
+ self._persist_after_mutation()
1655
+ return response
1656
+
1657
+ def apply_mutations(self, mutations, request_id, command_text,
1658
+ expected_revision=None):
1659
+ """Apply a batch atomically under one new revision (I31).
1660
+
1661
+ Every mutation is validated before anything lands: any
1662
+ failure names its document and leaves the revision, the
1663
+ files, and the log untouched. Only a fully valid batch at
1664
+ the live head advances once and appends a single
1665
+ mutations_applied event.
1666
+
1667
+ Args:
1668
+ mutations: Non-empty list of {op, path, content?}
1669
+ mappings; op is write (needs text content) or
1670
+ delete (takes none).
1671
+ request_id: Client envelope id for idempotent replay.
1672
+ command_text: YAML command text for identity comparison.
1673
+ expected_revision: Revision the writer read.
1674
+
1675
+ Returns:
1676
+ Mutation mapping with revision transition, new head,
1677
+ actor, joined diff summary, rewind handle, the single
1678
+ appended event, per-mutation results, cursor, and
1679
+ request id.
1680
+
1681
+ Raises:
1682
+ ProjectError: request_conflict for a reused id with
1683
+ new text; invalid_command for a malformed batch
1684
+ (names the document when one is addressable);
1685
+ invalid_path or path_escape for a bad path;
1686
+ unknown_document for a delete of a missing
1687
+ document; revision_conflict for a stale head;
1688
+ invalid_ledger when a durable write fails (best
1689
+ effort rollback already attempted).
1690
+
1691
+ Caution: All-or-nothing means one revision for the whole
1692
+ batch — a half-landed batch is a bug, never a state.
1693
+ """
1694
+ self._refresh_durable()
1695
+ replay = self._check_replay(request_id, command_text)
1696
+ if replay is not None:
1697
+ return replay
1698
+ if not isinstance(mutations, list) or not mutations:
1699
+ raise ProjectError(
1700
+ "invalid_command",
1701
+ "apply_mutations needs a non-empty mutations list.",
1702
+ )
1703
+ prepared = []
1704
+ for index, item in enumerate(mutations):
1705
+ if not isinstance(item, dict):
1706
+ raise ProjectError(
1707
+ "invalid_command",
1708
+ f"Mutation {index} must be a mapping.",
1709
+ )
1710
+ op = item.get("op")
1711
+ raw_path = item.get("path")
1712
+ if op not in ("write", "delete"):
1713
+ if isinstance(raw_path, str):
1714
+ raise ProjectError(
1715
+ "invalid_command",
1716
+ f"Unknown op {op!r} for document {raw_path}.",
1717
+ )
1718
+ raise ProjectError(
1719
+ "invalid_command",
1720
+ f"Mutation {index} needs op write/delete.",
1721
+ )
1722
+ if not isinstance(raw_path, str):
1723
+ raise ProjectError(
1724
+ "invalid_command",
1725
+ f"Mutation {index} needs a text path.",
1726
+ )
1727
+ canonical = _canonical_doc_path(raw_path)
1728
+ _check_reserved_doc(canonical, raw_path)
1729
+ if op == "write":
1730
+ unknown = set(item) - {"op", "path", "content"}
1731
+ if unknown:
1732
+ raise ProjectError(
1733
+ "invalid_command",
1734
+ f"Unknown fields for document {raw_path}: "
1735
+ f"{', '.join(sorted(unknown))}.",
1736
+ )
1737
+ content = item.get("content")
1738
+ if "content" not in item or not isinstance(
1739
+ content, str
1740
+ ):
1741
+ raise ProjectError(
1742
+ "invalid_command",
1743
+ f"Write needs text content for document "
1744
+ f"{raw_path}.",
1745
+ )
1746
+ if len(content.encode("utf-8")) > _MAX_DOC_BYTES:
1747
+ raise ProjectError(
1748
+ "invalid_command",
1749
+ f"Document exceeds the size limit: {raw_path}.",
1750
+ )
1751
+ else:
1752
+ unknown = set(item) - {"op", "path"}
1753
+ if unknown:
1754
+ raise ProjectError(
1755
+ "invalid_command",
1756
+ f"Unknown fields for document {raw_path}: "
1757
+ f"{', '.join(sorted(unknown))}.",
1758
+ )
1759
+ if "content" in item:
1760
+ raise ProjectError(
1761
+ "invalid_command",
1762
+ f"Delete takes no content for document "
1763
+ f"{raw_path}.",
1764
+ )
1765
+ content = None
1766
+ old = self._read_doc_text(canonical, raw_path)
1767
+ if op == "delete" and old is None:
1768
+ raise ProjectError(
1769
+ "unknown_document",
1770
+ f"No such document: {raw_path}.",
1771
+ )
1772
+ prepared.append((op, canonical, content, old, raw_path))
1773
+ self._gate_revision(expected_revision)
1774
+ previous = self._current_revision
1775
+ self._ensure_snapshot(previous)
1776
+ applied = []
1777
+ try:
1778
+ for op, canonical, content, _old, raw_path in prepared:
1779
+ if op == "write":
1780
+ self._write_doc_text(canonical, content, raw_path)
1781
+ else:
1782
+ self._delete_doc_path(canonical, raw_path)
1783
+ applied.append((canonical, _old))
1784
+ except ProjectError:
1785
+ for canonical, old in reversed(applied):
1786
+ try:
1787
+ if old is None:
1788
+ self._delete_doc_path(canonical, canonical)
1789
+ else:
1790
+ self._write_doc_text(
1791
+ canonical, old, canonical
1792
+ )
1793
+ except Exception:
1794
+ pass
1795
+ raise
1796
+ except OSError as exc:
1797
+ for canonical, old in reversed(applied):
1798
+ try:
1799
+ if old is None:
1800
+ self._delete_doc_path(canonical, canonical)
1801
+ else:
1802
+ self._write_doc_text(
1803
+ canonical, old, canonical
1804
+ )
1805
+ except Exception:
1806
+ pass
1807
+ raise ProjectError(
1808
+ "invalid_ledger",
1809
+ f"Batch write failed; rolled back: {exc}.",
1810
+ ) from exc
1811
+ advanced = _advance_revision(previous)
1812
+ try:
1813
+ self._capture_snapshot(advanced)
1814
+ except ProjectError:
1815
+ for _op, canonical, _content, old, _raw in reversed(prepared):
1816
+ try:
1817
+ if old is None:
1818
+ self._delete_doc_path(canonical, canonical)
1819
+ else:
1820
+ self._write_doc_text(
1821
+ canonical, old, canonical
1822
+ )
1823
+ except Exception:
1824
+ pass
1825
+ raise
1826
+ self._current_revision = advanced
1827
+ parts = []
1828
+ results = []
1829
+ for op, canonical, content, old, _raw in prepared:
1830
+ if op == "write":
1831
+ new_bytes = len(content.encode("utf-8"))
1832
+ if old is None:
1833
+ parts.append(
1834
+ f"created {canonical} ({new_bytes} bytes)"
1835
+ )
1836
+ else:
1837
+ parts.append(
1838
+ f"updated {canonical} "
1839
+ f"({len(old.encode('utf-8'))} -> "
1840
+ f"{new_bytes} bytes)"
1841
+ )
1842
+ results.append(
1843
+ {"op": "write", "path": canonical, "bytes": new_bytes}
1844
+ )
1845
+ else:
1846
+ old_bytes = len(old.encode("utf-8"))
1847
+ parts.append(f"deleted {canonical} ({old_bytes} bytes)")
1848
+ results.append(
1849
+ {"op": "delete", "path": canonical, "bytes": old_bytes}
1850
+ )
1851
+ diff = "; ".join(parts)
1852
+ record = self._append_event(
1853
+ "mutations_applied",
1854
+ request_id,
1855
+ {
1856
+ "revision": self._current_revision,
1857
+ "operation": "apply_mutations",
1858
+ "mutations": [
1859
+ {"op": op, "path": canonical}
1860
+ for op, canonical, _, _, _ in prepared
1861
+ ],
1862
+ "diff_summary": diff,
1863
+ },
1864
+ )
1865
+ response = {
1866
+ "project_id": self._project_id,
1867
+ "previous_revision": previous,
1868
+ "current_revision": self._current_revision,
1869
+ "revision_transition": {
1870
+ "from": previous,
1871
+ "to": self._current_revision,
1872
+ },
1873
+ "actor": self._actor,
1874
+ "diff_summary": diff,
1875
+ "rewind_handle": {
1876
+ "project_id": self._project_id,
1877
+ "revision": previous,
1878
+ "request_id": request_id,
1879
+ },
1880
+ "events": [record],
1881
+ "results": results,
1882
+ "event_cursor": self._cursor(),
1883
+ "request_id": request_id,
1884
+ }
1885
+ self._requests[request_id] = (command_text, response)
1886
+ self._persist_after_mutation()
1887
+ return response
1888
+
1889
+ def place_wire(self, segments, layer, endpoints, request_id,
1890
+ command_text, expected_revision=None):
1891
+ """Place one wire: gate, transact, advance, log.
1892
+
1893
+ A resubmitted envelope returns its stored response without
1894
+ advancing; a new envelope gates its expected_revision, and
1895
+ only an accepted placement advances the revision and
1896
+ appends the place_wire event carrying the entity receipt.
1897
+
1898
+ Args:
1899
+ segments: Non-empty list of integer polylines.
1900
+ layer: Layer name for every segment.
1901
+ endpoints: [a, b] endpoint mappings; each ref is a
1902
+ pin/port/junction/net entity ID or null when
1903
+ dangling.
1904
+ request_id: Client envelope id; resubmitting the
1905
+ identical text returns the identical response
1906
+ without advancing.
1907
+ command_text: YAML command text for identity comparison.
1908
+ expected_revision: Revision the writer read.
1909
+
1910
+ Returns:
1911
+ Mutation mapping with the wire receipt, revision
1912
+ transition, new head, actor, diff summary, rewind
1913
+ handle, the appended event, cursor, and request id.
1914
+
1915
+ Raises:
1916
+ ProjectError: request_conflict for a reused id with
1917
+ new text; revision_conflict for a stale head;
1918
+ unknown_ref for a ref naming no entity;
1919
+ invalid_ref for a wire ref; invalid_wire for a
1920
+ malformed wire; unsupported_operation without the
1921
+ ENGINE-ECS entity store.
1922
+
1923
+ Caution: Every accepted placement advances the revision —
1924
+ a second writer at the old head fails instead of
1925
+ merging.
1926
+ """
1927
+ return self._wire_mutation(
1928
+ "place_wire", request_id, command_text, expected_revision,
1929
+ lambda store: wireplace.place_wire(
1930
+ store, self._actor, segments, layer, endpoints
1931
+ ),
1932
+ lambda receipt: f"placed wire {receipt['wire']} on {layer}",
1933
+ )
1934
+
1935
+ def place_junction(self, xy, layer, request_id, command_text,
1936
+ expected_revision=None):
1937
+ """Place one junction dot: gate, transact, advance, log.
1938
+
1939
+ Args:
1940
+ xy: Integer [x, y] dot position.
1941
+ layer: Layer name for the dot.
1942
+ request_id: Client envelope id for idempotent replay.
1943
+ command_text: YAML command text for identity comparison.
1944
+ expected_revision: Revision the writer read.
1945
+
1946
+ Returns:
1947
+ Mutation mapping with the junction receipt, revision
1948
+ transition, new head, actor, diff summary, rewind
1949
+ handle, the appended event, cursor, and request id.
1950
+
1951
+ Raises:
1952
+ ProjectError: request_conflict for a reused id with
1953
+ new text; revision_conflict for a stale head;
1954
+ invalid_junction for a malformed dot;
1955
+ unsupported_operation without the ENGINE-ECS
1956
+ entity store.
1957
+
1958
+ Caution: Every accepted placement advances the revision —
1959
+ a second writer at the old head fails instead of
1960
+ merging.
1961
+ """
1962
+ return self._wire_mutation(
1963
+ "place_junction", request_id, command_text, expected_revision,
1964
+ lambda store: wireplace.place_junction(
1965
+ store, self._actor, xy, layer
1966
+ ),
1967
+ lambda receipt: f"placed junction {receipt['junction']}",
1968
+ )
1969
+
1970
+ def connect_endpoint(self, wire_id, end, ref, request_id,
1971
+ command_text, expected_revision=None):
1972
+ """Point one wire endpoint at a new terminus: gate, run, log.
1973
+
1974
+ Geometry never moves — only the ref changes — and a
1975
+ no-change call returns the head unadvanced with an empty
1976
+ event list instead of logging a phantom revision.
1977
+
1978
+ Args:
1979
+ wire_id: Wire entity to rewire.
1980
+ end: "a" or "b" selecting the endpoint.
1981
+ ref: Pin/port/junction/net entity ID, or null to
1982
+ dangle the endpoint.
1983
+ request_id: Client envelope id for idempotent replay.
1984
+ command_text: YAML command text for identity comparison.
1985
+ expected_revision: Revision the writer read.
1986
+
1987
+ Returns:
1988
+ Mutation mapping with the connect receipt, revision
1989
+ transition, actor, diff summary, rewind handle, the
1990
+ appended event, cursor, and request id — or the
1991
+ unadvanced noop mapping when the ref is already set.
1992
+
1993
+ Raises:
1994
+ ProjectError: request_conflict for a reused id with
1995
+ new text; revision_conflict for a stale head;
1996
+ unknown_wire for a missing wire; invalid_endpoint
1997
+ for a bad selector; unknown_ref for a missing
1998
+ entity; invalid_ref for a wire/self ref;
1999
+ unsupported_operation without the ENGINE-ECS
2000
+ entity store.
2001
+ """
2002
+ return self._wire_mutation(
2003
+ "connect_endpoint", request_id, command_text,
2004
+ expected_revision,
2005
+ lambda store: wireplace.connect_endpoint(
2006
+ store, self._actor, wire_id, end, ref
2007
+ ),
2008
+ lambda receipt: (
2009
+ f"connected wire {wire_id} end {end} to {ref!r}"
2010
+ ),
2011
+ )
2012
+
2013
+ def move_wire(self, wire_id, dx, dy, request_id, command_text,
2014
+ expected_revision=None):
2015
+ """Translate one wire: gate, transact, advance, log.
2016
+
2017
+ Refs survive the move; a zero move returns the head
2018
+ unadvanced with an empty event list.
2019
+
2020
+ Args:
2021
+ wire_id: Wire entity to move.
2022
+ dx: Integer x translation.
2023
+ dy: Integer y translation.
2024
+ request_id: Client envelope id for idempotent replay.
2025
+ command_text: YAML command text for identity comparison.
2026
+ expected_revision: Revision the writer read.
2027
+
2028
+ Returns:
2029
+ Mutation mapping with the move receipt, revision
2030
+ transition, actor, diff summary, rewind handle, the
2031
+ appended event, cursor, and request id — or the
2032
+ unadvanced noop mapping for a zero move.
2033
+
2034
+ Raises:
2035
+ ProjectError: request_conflict for a reused id with
2036
+ new text; revision_conflict for a stale head;
2037
+ unknown_wire for a missing wire; invalid_geometry
2038
+ for non-integer deltas; unsupported_operation
2039
+ without the ENGINE-ECS entity store.
2040
+ """
2041
+ return self._wire_mutation(
2042
+ "move_wire", request_id, command_text, expected_revision,
2043
+ lambda store: wireplace.move_wire(
2044
+ store, self._actor, wire_id, dx, dy
2045
+ ),
2046
+ lambda receipt: f"moved wire {wire_id} by ({dx}, {dy})",
2047
+ )
2048
+
2049
+ def split_wire(self, wire_id, at, request_id, command_text,
2050
+ expected_revision=None):
2051
+ """Split one wire at an interior point: gate, run, log.
2052
+
2053
+ The junction dot, the head rewrite, and the new tail wire
2054
+ land in one revision under one event.
2055
+
2056
+ Args:
2057
+ wire_id: Wire entity to split.
2058
+ at: Integer [x, y] point on the polyline interior.
2059
+ request_id: Client envelope id for idempotent replay.
2060
+ command_text: YAML command text for identity comparison.
2061
+ expected_revision: Revision the writer read.
2062
+
2063
+ Returns:
2064
+ Mutation mapping with the split receipt (wire,
2065
+ junction, new_wire), revision transition, new head,
2066
+ actor, diff summary, rewind handle, the appended
2067
+ event, cursor, and request id.
2068
+
2069
+ Raises:
2070
+ ProjectError: request_conflict for a reused id with
2071
+ new text; revision_conflict for a stale head;
2072
+ unknown_wire for a missing wire; invalid_split for
2073
+ a point off the interior or a multi-polyline wire;
2074
+ unsupported_operation without the ENGINE-ECS
2075
+ entity store.
2076
+ """
2077
+ return self._wire_mutation(
2078
+ "split_wire", request_id, command_text, expected_revision,
2079
+ lambda store: wireplace.split_wire(
2080
+ store, self._actor, wire_id, at
2081
+ ),
2082
+ lambda receipt: (
2083
+ f"split wire {wire_id} at {at} "
2084
+ f"(junction {receipt['junction']}, "
2085
+ f"tail {receipt['new_wire']})"
2086
+ ),
2087
+ )
2088
+
2089
+ def delete_wire(self, wire_id, request_id, command_text,
2090
+ expected_revision=None):
2091
+ """Delete one wire: gate, remove, advance, log.
2092
+
2093
+ Referenced junctions persist; Net entities rematerialize
2094
+ on the next materialize_nets call.
2095
+
2096
+ Args:
2097
+ wire_id: Wire entity to delete.
2098
+ request_id: Client envelope id for idempotent replay.
2099
+ command_text: YAML command text for identity comparison.
2100
+ expected_revision: Revision the writer read.
2101
+
2102
+ Returns:
2103
+ Mutation mapping with the delete receipt, revision
2104
+ transition, new head, actor, diff summary, rewind
2105
+ handle, the appended event, cursor, and request id.
2106
+
2107
+ Raises:
2108
+ ProjectError: request_conflict for a reused id with
2109
+ new text; revision_conflict for a stale head;
2110
+ unknown_wire for a missing wire;
2111
+ unsupported_operation without the ENGINE-ECS
2112
+ entity store.
2113
+ """
2114
+ return self._wire_mutation(
2115
+ "delete_wire", request_id, command_text, expected_revision,
2116
+ lambda store: wireplace.delete_wire(
2117
+ store, self._actor, wire_id
2118
+ ),
2119
+ lambda receipt: f"deleted wire {wire_id}",
2120
+ )
2121
+
2122
+ def delete_junction(self, junction_id, request_id, command_text,
2123
+ expected_revision=None):
2124
+ """Delete one unreferenced junction: gate, remove, log.
2125
+
2126
+ Args:
2127
+ junction_id: Junction entity to delete.
2128
+ request_id: Client envelope id for idempotent replay.
2129
+ command_text: YAML command text for identity comparison.
2130
+ expected_revision: Revision the writer read.
2131
+
2132
+ Returns:
2133
+ Mutation mapping with the delete receipt, revision
2134
+ transition, new head, actor, diff summary, rewind
2135
+ handle, the appended event, cursor, and request id.
2136
+
2137
+ Raises:
2138
+ ProjectError: request_conflict for a reused id with
2139
+ new text; revision_conflict for a stale head;
2140
+ unknown_junction for a missing junction;
2141
+ junction_in_use naming the referencing wires;
2142
+ unsupported_operation without the ENGINE-ECS
2143
+ entity store.
2144
+ """
2145
+ return self._wire_mutation(
2146
+ "delete_junction", request_id, command_text,
2147
+ expected_revision,
2148
+ lambda store: wireplace.delete_junction(
2149
+ store, self._actor, junction_id
2150
+ ),
2151
+ lambda receipt: f"deleted junction {junction_id}",
2152
+ )
2153
+
2154
+ def materialize_nets(self, pins, request_id, command_text,
2155
+ labels=None, ports=None, cell="top",
2156
+ named_refs=None, expected_revision=None):
2157
+ """Extract connectivity, upsert Net entities: gate, run, log.
2158
+
2159
+ Extraction always rebuilds from the stored wires; Nets
2160
+ keyed by canonical name are created or refreshed, vanished
2161
+ names pruned, all in one revision. An unchanged world
2162
+ returns the head unadvanced with an empty event list.
2163
+
2164
+ Args:
2165
+ pins: Caller pin records [{entity, instance, pin, xy}].
2166
+ request_id: Client envelope id for idempotent replay.
2167
+ command_text: YAML command text for identity comparison.
2168
+ labels: Caller label records [{name, xy}].
2169
+ ports: Caller port records [{name, direction}].
2170
+ cell: Top cell name.
2171
+ named_refs: {entity ID: net name} for port-style refs.
2172
+ expected_revision: Revision the writer read.
2173
+
2174
+ Returns:
2175
+ Mutation mapping with the materialize receipt (nets,
2176
+ created/updated/pruned, unresolved), revision
2177
+ transition, actor, diff summary, rewind handle, the
2178
+ appended event, cursor, and request id — or the
2179
+ unadvanced noop mapping when every Net matches.
2180
+
2181
+ Raises:
2182
+ ProjectError: request_conflict for a reused id with
2183
+ new text; revision_conflict for a stale head;
2184
+ invalid_pin / invalid_label / invalid_port /
2185
+ invalid_cell / invalid_ref for malformed inputs;
2186
+ extraction_failed when extraction refuses the
2187
+ world; unsupported_operation without the
2188
+ ENGINE-ECS entity store.
2189
+ """
2190
+ return self._wire_mutation(
2191
+ "materialize_nets", request_id, command_text,
2192
+ expected_revision,
2193
+ lambda store: wireplace.materialize_nets(
2194
+ store, self._actor, pins, labels=labels or (),
2195
+ ports=ports or (), cell=cell, named_refs=named_refs,
2196
+ ),
2197
+ lambda receipt: (
2198
+ f"materialized {len(receipt['nets'])} nets "
2199
+ f"({len(receipt['created'])} created, "
2200
+ f"{len(receipt['updated'])} updated, "
2201
+ f"{len(receipt['pruned'])} pruned)"
2202
+ ),
2203
+ )
2204
+
2205
+ def rewind(self, target_revision, request_id, command_text,
2206
+ expected_revision=None):
2207
+ """Restore managed bytes to a prior revision under a new head.
2208
+
2209
+ The target snapshot replaces every managed document
2210
+ byte-identically; runs, the ledger, and the snapshots
2211
+ themselves are left alone. History stays append-only: the
2212
+ restore advances from the live head and appends a single
2213
+ `project_rewound` event recording the target. Linked caller
2214
+ trees are never written — files outside `.rocket/` are
2215
+ listed in the response as left untouched.
2216
+
2217
+ Args:
2218
+ target_revision: Snapshot revision to restore.
2219
+ request_id: Client envelope id for idempotent replay.
2220
+ command_text: YAML command text for identity comparison.
2221
+ expected_revision: Revision the writer read.
2222
+
2223
+ Returns:
2224
+ Mutation mapping with revision transition, target
2225
+ revision, new head, actor, diff summary, external-file
2226
+ report, rewind handle, the appended event, cursor, and
2227
+ request id.
2228
+
2229
+ Raises:
2230
+ ProjectError: request_conflict for a reused id with new
2231
+ text; invalid_command for a missing target or a
2232
+ target that equals the live head; unknown_revision
2233
+ for a revision with no snapshot; revision_conflict
2234
+ for a stale head; invalid_ledger when the durable
2235
+ restore fails (best-effort rollback already
2236
+ attempted).
2237
+
2238
+ Caution: Rewinding advances — the new head is not the target
2239
+ id, and stale writers fail instead of merging. Caller
2240
+ files outside `.rocket/` are reported, never touched.
2241
+ """
2242
+ self._refresh_durable()
2243
+ replay = self._check_replay(request_id, command_text)
2244
+ if replay is not None:
2245
+ return replay
2246
+ if not isinstance(target_revision, str) or not target_revision:
2247
+ raise ProjectError(
2248
+ "invalid_command",
2249
+ "rewind needs a target_revision argument.",
2250
+ )
2251
+ snapshot = self._load_snapshot(target_revision)
2252
+ if snapshot is None:
2253
+ raise ProjectError(
2254
+ "unknown_revision",
2255
+ f"No snapshot for revision: {target_revision}.",
2256
+ )
2257
+ if target_revision == self._current_revision:
2258
+ raise ProjectError(
2259
+ "invalid_command",
2260
+ f"Already at revision {target_revision}.",
2261
+ )
2262
+ self._gate_revision(expected_revision)
2263
+ previous = self._current_revision
2264
+ self._ensure_snapshot(previous)
2265
+ if self._dir is None:
2266
+ self._docs = dict(snapshot)
2267
+ else:
2268
+ pre = self._current_bytes()
2269
+ self._restore_snapshot_bytes(snapshot, pre)
2270
+ advanced = _advance_revision(previous)
2271
+ if self._dir is None:
2272
+ self._snapshots[advanced] = dict(self._docs)
2273
+ else:
2274
+ try:
2275
+ self._capture_snapshot(advanced)
2276
+ except ProjectError:
2277
+ try:
2278
+ self._clear_docs()
2279
+ self._write_bytes_map(pre)
2280
+ except Exception:
2281
+ pass
2282
+ raise
2283
+ self._current_revision = advanced
2284
+ external = self._linked_external_files()
2285
+ count = len(snapshot)
2286
+ noun = "doc" if count == 1 else "docs"
2287
+ diff = (
2288
+ f"rewound {previous} to {target_revision} "
2289
+ f"(restored {count} {noun} as {advanced})"
2290
+ )
2291
+ record = self._append_event(
2292
+ "project_rewound",
2293
+ request_id,
2294
+ {
2295
+ "revision": self._current_revision,
2296
+ "operation": "rewind",
2297
+ "target_revision": target_revision,
2298
+ "previous_revision": previous,
2299
+ "diff_summary": diff,
2300
+ "external_files": [e["path"] for e in external],
2301
+ },
2302
+ )
2303
+ response = {
2304
+ "project_id": self._project_id,
2305
+ "previous_revision": previous,
2306
+ "current_revision": self._current_revision,
2307
+ "revision_transition": {
2308
+ "from": previous,
2309
+ "to": self._current_revision,
2310
+ },
2311
+ "target_revision": target_revision,
2312
+ "actor": self._actor,
2313
+ "diff_summary": diff,
2314
+ "external_files": external,
2315
+ "rewind_handle": {
2316
+ "project_id": self._project_id,
2317
+ "revision": previous,
2318
+ "request_id": request_id,
2319
+ },
2320
+ "events": [record],
2321
+ "event_cursor": self._cursor(),
2322
+ "request_id": request_id,
2323
+ }
2324
+ self._requests[request_id] = (command_text, response)
2325
+ self._persist_after_mutation()
2326
+ return response
2327
+
2328
+ def apply_transaction(self, changes, request_id, command_text,
2329
+ expected_revision=None):
2330
+ """Apply one atomic batch of entity changes (API-V1-EDITORS).
2331
+
2332
+ Alias creation resolves LocalRef before persistence; every
2333
+ change gates CAS, and a failed late change rolls back.
2334
+
2335
+ Args:
2336
+ changes: Change mappings with an op each.
2337
+ request_id: Client envelope id for idempotent replay.
2338
+ command_text: YAML command text for identity comparison.
2339
+ expected_revision: Revision the writer read.
2340
+
2341
+ Returns:
2342
+ Mutation mapping with receipt, events, and cursor.
2343
+
2344
+ Raises:
2345
+ ProjectError: request_conflict, revision_conflict,
2346
+ invalid_command, or the CAS refusal code.
2347
+ """
2348
+ return _editors.editor_mutation(self,
2349
+ "apply_transaction", request_id, command_text,
2350
+ expected_revision,
2351
+ lambda _store, rid, rtext, live: (
2352
+ _transactions.apply_transaction(
2353
+ self, rid, rtext, changes, live)),
2354
+ lambda receipt: (
2355
+ f"applied {receipt.get('applied', len(changes))} "
2356
+ "changes"),
2357
+ )
2358
+
2359
+ def create_shape(self, shape_type, layer, data, request_id,
2360
+ command_text, units="nm", profile=None,
2361
+ anchor=None, expected_revision=None):
2362
+ """Create one layout shape entity with provenance.
2363
+
2364
+ Args:
2365
+ shape_type: One of rect, polygon, path, label.
2366
+ layer: Stable layer lid the shape is drawn on.
2367
+ data: Shape payload for the type.
2368
+ request_id: Client envelope id for idempotent replay.
2369
+ command_text: YAML command text for identity comparison.
2370
+ units: Coordinate units label.
2371
+ profile: Technology profile pin for provenance.
2372
+ anchor: Optional cell-port anchor.
2373
+ expected_revision: Revision the writer read.
2374
+
2375
+ Returns:
2376
+ Mutation mapping with receipt, events, and cursor.
2377
+
2378
+ Raises:
2379
+ ProjectError: request_conflict, revision_conflict,
2380
+ invalid_command for a malformed payload.
2381
+ """
2382
+ return _editors.editor_mutation(self,
2383
+ "create_shape", request_id, command_text,
2384
+ expected_revision,
2385
+ lambda _store, rid, rtext, live: _geometry.create_shape(
2386
+ self, rid, rtext, shape_type, layer, data,
2387
+ units=units, profile=profile, anchor=anchor,
2388
+ expected_revision=live),
2389
+ lambda receipt: (
2390
+ f"created {shape_type} shape "
2391
+ f"({receipt.get('entity')})"),
2392
+ )
2393
+
2394
+ def create_sheet(self, name, request_id, command_text, title="",
2395
+ expected_revision=None):
2396
+ """Create one schematic sheet.
2397
+
2398
+ Args:
2399
+ name: Unique sheet name.
2400
+ request_id: Client envelope id for idempotent replay.
2401
+ command_text: YAML command text for identity comparison.
2402
+ title: Human title shown on the sheet frame.
2403
+ expected_revision: Revision the writer read.
2404
+
2405
+ Returns:
2406
+ Mutation mapping with receipt, events, and cursor.
2407
+
2408
+ Raises:
2409
+ ProjectError: request_conflict, revision_conflict,
2410
+ invalid_sheet, sheet_exists.
2411
+ """
2412
+ return _editors.editor_mutation(self,
2413
+ "create_sheet", request_id, command_text,
2414
+ expected_revision,
2415
+ lambda store, _rid, _rtext, _live: _sheets.create_sheet(
2416
+ store, self._actor, name, title=title),
2417
+ lambda receipt: (
2418
+ f"created sheet {name} ({receipt.get('sheet')})"),
2419
+ )
2420
+
2421
+ def place_instance(self, sheet, symbol, x, y, request_id,
2422
+ command_text, rotation=0, mirror=False,
2423
+ properties=None, ref="",
2424
+ expected_revision=None):
2425
+ """Place one symbol instance on a sheet.
2426
+
2427
+ Args:
2428
+ sheet: Sheet entity id hosting the instance.
2429
+ symbol: Bare project symbol name or scoped upstream ref.
2430
+ x: X position in schematic units.
2431
+ y: Y position in schematic units.
2432
+ request_id: Client envelope id for idempotent replay.
2433
+ command_text: YAML command text for identity comparison.
2434
+ rotation: Rotation in degrees (0/90/180/270).
2435
+ mirror: Mirror flag.
2436
+ properties: Text-to-text instance properties.
2437
+ ref: Annotation reference ("" means unannotated).
2438
+ expected_revision: Revision the writer read.
2439
+
2440
+ Returns:
2441
+ Mutation mapping with receipt, events, and cursor.
2442
+
2443
+ Raises:
2444
+ ProjectError: request_conflict, revision_conflict,
2445
+ unknown_sheet, unknown_symbol, invalid_instance.
2446
+ """
2447
+ return _editors.editor_mutation(self,
2448
+ "place_instance", request_id, command_text,
2449
+ expected_revision,
2450
+ lambda store, _rid, _rt, _live: _instances.place_instance(
2451
+ store, self._actor, sheet, symbol, x, y,
2452
+ rotation=rotation, mirror=mirror,
2453
+ properties=properties or {}, ref=ref),
2454
+ lambda receipt: (
2455
+ f"placed {symbol} ({receipt.get('instance')})"),
2456
+ )
2457
+
2458
+ def bind_block(self, parent_sheet, child_sheet, instance,
2459
+ request_id, command_text, expected_revision=None):
2460
+ """Bind a child sheet under a parent through a block instance.
2461
+
2462
+ Args:
2463
+ parent_sheet: Parent sheet entity id.
2464
+ child_sheet: Child sheet entity id.
2465
+ instance: Block instance entity id in the parent.
2466
+ request_id: Client envelope id for idempotent replay.
2467
+ command_text: YAML command text for identity comparison.
2468
+ expected_revision: Revision the writer read.
2469
+
2470
+ Returns:
2471
+ Mutation mapping with receipt, events, and cursor.
2472
+
2473
+ Raises:
2474
+ ProjectError: request_conflict, revision_conflict,
2475
+ unknown_sheet, unknown_instance, binding_conflict,
2476
+ invalid_hierarchy.
2477
+ """
2478
+ return _editors.editor_mutation(self,
2479
+ "bind_block", request_id, command_text,
2480
+ expected_revision,
2481
+ lambda store, _rid, _rt, _live: _hierarchy.bind_block(
2482
+ store, self._actor, parent_sheet, child_sheet,
2483
+ instance),
2484
+ lambda receipt: (
2485
+ f"bound {child_sheet} under {parent_sheet}"),
2486
+ )
2487
+
2488
+ def create_symbol(self, name, pins, request_id, command_text,
2489
+ graphics=None, properties=None,
2490
+ expected_revision=None):
2491
+ """Create one project-owned symbol definition.
2492
+
2493
+ Args:
2494
+ name: Unique symbol name.
2495
+ pins: Pin records with name, pin_type, x, and y.
2496
+ request_id: Client envelope id for idempotent replay.
2497
+ command_text: YAML command text for identity comparison.
2498
+ graphics: Editable graphics items.
2499
+ properties: Text-to-text symbol properties.
2500
+ expected_revision: Revision the writer read.
2501
+
2502
+ Returns:
2503
+ Mutation mapping with receipt, events, and cursor.
2504
+
2505
+ Raises:
2506
+ ProjectError: request_conflict, revision_conflict,
2507
+ invalid_symbol, symbol_exists.
2508
+ """
2509
+ return _editors.editor_mutation(self,
2510
+ "create_symbol", request_id, command_text,
2511
+ expected_revision,
2512
+ lambda store, _rid, _rt, _live: _symbols.create_symbol(
2513
+ store, self._actor, name, pins, graphics=graphics,
2514
+ properties=properties),
2515
+ lambda receipt: (
2516
+ f"created symbol {name} ({receipt.get('symbol')})"),
2517
+ )
2518
+
2519
+ def create_cell(self, name, ports, request_id, command_text,
2520
+ properties=None, expected_revision=None):
2521
+ """Create one physical cell definition.
2522
+
2523
+ Args:
2524
+ name: Unique cell name.
2525
+ ports: Port records for the cell.
2526
+ request_id: Client envelope id for idempotent replay.
2527
+ command_text: YAML command text for identity comparison.
2528
+ properties: Text-to-text cell properties.
2529
+ expected_revision: Revision the writer read.
2530
+
2531
+ Returns:
2532
+ Mutation mapping with receipt, events, and cursor.
2533
+
2534
+ Raises:
2535
+ ProjectError: request_conflict, revision_conflict,
2536
+ invalid_cell, cell_exists.
2537
+ """
2538
+ return _editors.editor_mutation(self,
2539
+ "create_cell", request_id, command_text,
2540
+ expected_revision,
2541
+ lambda store, _rid, _rt, _live: _cells.create_cell(
2542
+ store, self._actor, name, ports,
2543
+ properties=properties),
2544
+ lambda receipt: (
2545
+ f"created cell {name} ({receipt.get('cell')})"),
2546
+ )
2547
+
2548
+ def map_pins(self, symbol, cell, pin_map, request_id,
2549
+ command_text, expected_revision=None):
2550
+ """Create or remap the pin map for one symbol-cell pair.
2551
+
2552
+ Args:
2553
+ symbol: Project symbol name being linked.
2554
+ cell: Project cell name (or scoped upstream ref).
2555
+ pin_map: Explicit [{symPin, cellPin}] entries.
2556
+ request_id: Client envelope id for idempotent replay.
2557
+ command_text: YAML command text for identity comparison.
2558
+ expected_revision: Revision the writer read.
2559
+
2560
+ Returns:
2561
+ Mutation mapping with receipt, events, and cursor.
2562
+
2563
+ Raises:
2564
+ ProjectError: request_conflict, revision_conflict,
2565
+ unknown_symbol, unknown_cell, invalid_linkage.
2566
+ """
2567
+ return _editors.editor_mutation(self,
2568
+ "map_pins", request_id, command_text,
2569
+ expected_revision,
2570
+ lambda store, _rid, _rt, _live: _linkage.map_pins(
2571
+ store, self._actor, symbol, cell, pin_map),
2572
+ lambda receipt: f"mapped {symbol} to {cell}",
2573
+ )
2574
+
2575
+ def bind_layers(self, profile, layers, request_id,
2576
+ command_text, source="profile",
2577
+ version="unknown", expected_revision=None):
2578
+ """Bind project layers to a technology profile and save.
2579
+
2580
+ Builds the catalog from the given layers, binds it, and
2581
+ saves the report as `layers/bindings.yaml`. A missing
2582
+ binding never reinterprets geometry.
2583
+
2584
+ Args:
2585
+ profile: Technology profile mapping (name, version).
2586
+ layers: Layer entries with display attributes.
2587
+ request_id: Client envelope id for idempotent replay.
2588
+ command_text: YAML command text for identity comparison.
2589
+ source: Provenance label for the profile source.
2590
+ version: Profile version pinned into the report.
2591
+ expected_revision: Revision the writer read.
2592
+
2593
+ Returns:
2594
+ Mutation mapping with receipt, events, and cursor.
2595
+
2596
+ Raises:
2597
+ ProjectError: request_conflict, revision_conflict,
2598
+ invalid_command for a malformed catalog.
2599
+ """
2600
+ def _run(_store, rid, rtext, live):
2601
+ catalog = _layer_catalog.LayerCatalog(
2602
+ profile=dict(profile or {}))
2603
+ for entry in layers or []:
2604
+ catalog.add_layer(
2605
+ entry.get("name"), purpose=entry.get(
2606
+ "purpose", "drawing"),
2607
+ color=entry.get("color", "#ffffff"),
2608
+ order=entry.get("order", 0),
2609
+ visible=entry.get("visible", True))
2610
+ report = _layer_bindings.bind_layers(
2611
+ catalog, (profile or {}).get("layers", []),
2612
+ source=source, version=version)
2613
+ self.write_doc(
2614
+ "layers/bindings.yaml",
2615
+ _dump_yaml(report),
2616
+ f"{rid}:bindings", f"{rtext}\n#inner bindings",
2617
+ expected_revision=live,
2618
+ )
2619
+ return {
2620
+ "op": "bind_layers",
2621
+ "profile": report["profile"],
2622
+ "profile_version": report["profile_version"],
2623
+ "entries": report["entries"],
2624
+ "i28": {"profile": {
2625
+ "name": report["profile"],
2626
+ "version": report["profile_version"]}},
2627
+ }
2628
+ return _editors.editor_mutation(self,
2629
+ "bind_layers", request_id, command_text,
2630
+ expected_revision, _run,
2631
+ lambda receipt: (
2632
+ f"bound {len(receipt.get('entries', []))} layers "
2633
+ f"to {receipt.get('profile')}"),
2634
+ )
2635
+
2636
+ def annotate(self, request_id, command_text, prefixes=None,
2637
+ sheet=None, expected_revision=None):
2638
+ """Assign reference designators to unannotated instances.
2639
+
2640
+ Counters continue past the highest numeric suffix already
2641
+ on the sheet set; annotated instances keep their refs.
2642
+
2643
+ Args:
2644
+ request_id: Client envelope id for idempotent replay.
2645
+ command_text: YAML command text for identity comparison.
2646
+ prefixes: Optional per-symbol prefix overrides.
2647
+ sheet: Sheet entity id to scope to, or None for all.
2648
+ expected_revision: Revision the writer read.
2649
+
2650
+ Returns:
2651
+ Mutation mapping with receipt, events, and cursor.
2652
+
2653
+ Raises:
2654
+ ProjectError: request_conflict, revision_conflict.
2655
+ """
2656
+ return _editors.editor_mutation(self,
2657
+ "annotate", request_id, command_text,
2658
+ expected_revision,
2659
+ lambda store, _rid, _rt, _live: _frontend13.annotate(
2660
+ store, self._actor, prefixes=prefixes,
2661
+ sheet=sheet),
2662
+ lambda receipt: (
2663
+ f"annotated {len(receipt.get('annotated', []))} "
2664
+ "instances"),
2665
+ )
2666
+
2667
+ def create_testbench(self, name, kind, targets, request_id,
2668
+ command_text, probes=None, harness=None,
2669
+ template=None, expected_revision=None):
2670
+ """Create one scoped testbench block with editable source.
2671
+
2672
+ Validates the DUT scope, materializes the interface, renders
2673
+ the template source, stores the record, and saves the source.
2674
+
2675
+ Args:
2676
+ name: Block name (source saved as testbench/<name>).
2677
+ kind: Template kind (spice, hdl, or cocotb).
2678
+ targets: DUT target set (primary plus additional).
2679
+ request_id: Client envelope id for idempotent replay.
2680
+ command_text: YAML command text for identity comparison.
2681
+ probes: Probe mappings inside the DUT closure.
2682
+ harness: Explicit harness binding mapping.
2683
+ template: Reserved template override (unused).
2684
+ expected_revision: Revision the writer read.
2685
+
2686
+ Returns:
2687
+ Mutation mapping with receipt, events, and cursor.
2688
+
2689
+ Raises:
2690
+ ProjectError: request_conflict, revision_conflict,
2691
+ invalid_kind, invalid_target, overlapping_targets,
2692
+ illegal_scope.
2693
+ """
2694
+ return _editors.editor_mutation(self,
2695
+ "create_testbench", request_id, command_text,
2696
+ expected_revision,
2697
+ lambda _store, rid, rtext, live: _testbench.create_block(
2698
+ self, rid, rtext, name, kind, targets,
2699
+ probes or [], harness or {}, template=template,
2700
+ expected_revision=live),
2701
+ lambda receipt: (
2702
+ f"created {kind} testbench {name} "
2703
+ f"({receipt.get('testbench')})"),
2704
+ )
2705
+
2706
+ def regenerate_testbench(self, testbench, request_id,
2707
+ command_text, expected_revision=None):
2708
+ """Regenerate one block to a candidate without overwriting.
2709
+
2710
+ A fresh link returns a noop receipt; a drifted link renders
2711
+ the candidate beside the edited live source for adoption.
2712
+
2713
+ Args:
2714
+ testbench: Testbench entity id.
2715
+ request_id: Client envelope id for idempotent replay.
2716
+ command_text: YAML command text for identity comparison.
2717
+ expected_revision: Revision the writer read.
2718
+
2719
+ Returns:
2720
+ Mutation mapping with receipt, events, and cursor.
2721
+
2722
+ Raises:
2723
+ ProjectError: request_conflict, revision_conflict,
2724
+ unknown_entity.
2725
+ """
2726
+ return _editors.editor_mutation(self,
2727
+ "regenerate_testbench", request_id, command_text,
2728
+ expected_revision,
2729
+ lambda _store, rid, rtext, live: (
2730
+ _testbench.regenerate_block(
2731
+ self, rid, rtext, testbench,
2732
+ expected_revision=live)),
2733
+ lambda receipt: (
2734
+ "testbench fresh" if receipt.get("noop")
2735
+ else f"regenerated {testbench}"),
2736
+ )
2737
+
2738
+ def entity_revision(self, entity):
2739
+ """Read the live per-entity revision of one entity.
2740
+
2741
+ Args:
2742
+ entity: Engine-minted entity id to inspect.
2743
+
2744
+ Returns:
2745
+ Mapping with entity, head revision, and the live
2746
+ entity revision plus the I28 closure pin.
2747
+
2748
+ Raises:
2749
+ ProjectError: unknown_entity for a missing id.
2750
+ """
2751
+ revision = _cas.read_entity_revision(self, entity)
2752
+ return {
2753
+ "entity": entity,
2754
+ "entity_revision": revision,
2755
+ "current_revision": self._current_revision,
2756
+ "i28": pin_closure(
2757
+ self._current_revision,
2758
+ entities={entity: revision}),
2759
+ }
2760
+
2761
+ def erc_report(self, severities=None, pin_conflict=None,
2762
+ net_pins=None):
2763
+ """Run the ERC_TESTER rule set over the schematic.
2764
+
2765
+ Args:
2766
+ severities: Rule-to-severity overrides.
2767
+ pin_conflict: Pin-conflict severity overrides.
2768
+ net_pins: Materialized rows for the pin-conflict rule.
2769
+
2770
+ Returns:
2771
+ Findings with verdict plus revision and I28 pin.
2772
+
2773
+ Raises:
2774
+ ProjectError: invalid_command for a malformed config.
2775
+ """
2776
+ check = lambda store: _frontend13.erc_check( # noqa: E731
2777
+ store, severities=severities, pin_conflict=pin_conflict,
2778
+ net_pins=net_pins)
2779
+ return _editors.editor_read(self, "erc_check", check)
2780
+
2781
+ def graph_report(self, sheet=None, net=None, net_pins=None):
2782
+ """Report the connection graph or one net's occurrences.
2783
+
2784
+ Args:
2785
+ sheet: Sheet entity id to scope to, or None for all.
2786
+ net: Net name to collect, or None for the graph.
2787
+ net_pins: Materialized rows net collection needs.
2788
+
2789
+ Returns:
2790
+ Graph nodes/edges (or occurrence rows) with I28 pin.
2791
+
2792
+ Raises:
2793
+ ProjectError: invalid_command for a missing net.
2794
+ """
2795
+ if net is not None:
2796
+ if not isinstance(net_pins, list):
2797
+ raise ProjectError(
2798
+ "invalid_command",
2799
+ "net_occurrences needs net_pins rows.")
2800
+ return {
2801
+ "net": net,
2802
+ "occurrences": _frontend13.net_occurrences(
2803
+ net_pins, net),
2804
+ "current_revision": self._current_revision,
2805
+ "i28": pin_closure(self._current_revision),
2806
+ }
2807
+ graph = lambda store: _frontend13.connection_graph( # noqa: E731
2808
+ store, sheet=sheet)
2809
+ return _editors.editor_read(self, "connection_graph", graph)
2810
+
2811
+ def probe_report(self, instance, pin, net_pins=None):
2812
+ """Cross-probe one pin: symbol, cell, cell pin, net.
2813
+
2814
+ Without extracted net_pins the probe reports net_known
2815
+ False instead of guessing.
2816
+
2817
+ Args:
2818
+ instance: Instance entity id to probe.
2819
+ pin: Symbol pin to probe.
2820
+ net_pins: Materialized rows, or None when none ran.
2821
+
2822
+ Returns:
2823
+ Probe mapping with I28 pin.
2824
+
2825
+ Raises:
2826
+ ProjectError: unknown_instance, unknown_pin.
2827
+ """
2828
+ probe = lambda store: _frontend13.xprobe( # noqa: E731
2829
+ store, instance, pin, net_pins=net_pins)
2830
+ return _editors.editor_read(self, "xprobe", probe)
2831
+
2832
+ def plot_report(self, format="svg", sheet=None):
2833
+ """Export the schematic drawing (plot/export op).
2834
+
2835
+ Args:
2836
+ format: Drawing format (svg or json).
2837
+ sheet: Sheet entity id to scope to, or None for all.
2838
+
2839
+ Returns:
2840
+ Drawing with I28 pin.
2841
+
2842
+ Raises:
2843
+ ProjectError: invalid_format for an unknown format.
2844
+ """
2845
+ plot = lambda store: _frontend13.plot_export( # noqa: E731
2846
+ store, format=format, sheet=sheet)
2847
+ return _editors.editor_read(self, "plot_export", plot)
2848
+
2849
+ def netlist_report(self, language, sheet=None, title="top",
2850
+ net_pins=None, prior_text=None,
2851
+ adopted=False):
2852
+ """Write (or rewrite and diff) a netlist for the schematic.
2853
+
2854
+ Harness-owned instances stay excluded unless the design
2855
+ adopted the block. Dangling pins emit explicit stubs.
2856
+
2857
+ Args:
2858
+ language: Netlist language (spice or verilog).
2859
+ sheet: Sheet entity id to scope to, or None for all.
2860
+ title: Netlist/circuit title.
2861
+ net_pins: Materialized rows (required).
2862
+ prior_text: Previously emitted text for rewrites.
2863
+ adopted: True includes adopted harness instances.
2864
+
2865
+ Returns:
2866
+ Netlist text, counts, and I28 pin.
2867
+
2868
+ Raises:
2869
+ ProjectError: invalid_language, invalid_command.
2870
+ """
2871
+ if not isinstance(net_pins, list):
2872
+ raise ProjectError(
2873
+ "invalid_command",
2874
+ "Netlist emission needs net_pins rows.")
2875
+ rows = list(net_pins)
2876
+ if not adopted:
2877
+ excluded = _testbench.excluded_harness_instances(self)
2878
+ rows = [row for row in rows
2879
+ if row.get("instance") not in excluded]
2880
+ if prior_text is None:
2881
+ emit = lambda store: _frontend13.write_netlist( # noqa: E731
2882
+ store, language=language, sheet=sheet,
2883
+ title=title, net_pins=rows)
2884
+ else:
2885
+ emit = lambda store: _frontend13.update_netlist( # noqa: E731
2886
+ store, language, prior_text, sheet=sheet,
2887
+ title=title, net_pins=rows)
2888
+ return _editors.editor_read(self, "netlist", emit)
2889
+
2890
+ def capability_report(self, profile, operation, bindings=None):
2891
+ """Check whether one operation may run under a profile.
2892
+
2893
+ Args:
2894
+ profile: Technology profile mapping with name,
2895
+ version, source hash, and capabilities.
2896
+ operation: Operation name to check.
2897
+ bindings: Layer bindings the design needs.
2898
+
2899
+ Returns:
2900
+ Capability verdict with profile pin and I28 pin.
2901
+
2902
+ Raises:
2903
+ ProjectError: invalid_command for a malformed profile.
2904
+ """
2905
+ for key in ("name", "version", "source_hash"):
2906
+ if not isinstance(profile, dict) or not profile.get(key):
2907
+ raise ProjectError(
2908
+ "invalid_command",
2909
+ f"capability_report needs profile {key}.")
2910
+ tech = _process.TechProfile(
2911
+ profile["name"], profile["version"],
2912
+ profile["source_hash"],
2913
+ units=profile.get("units", "nm"),
2914
+ grid=profile.get("grid", 1),
2915
+ deck=profile.get("deck"),
2916
+ capabilities=profile.get("capabilities"),
2917
+ )
2918
+ result = _editors.editor_value_error(
2919
+ lambda: _process.check_capability(
2920
+ tech, operation, bindings or []))
2921
+ return {
2922
+ **result, "current_revision": self._current_revision,
2923
+ "i28": pin_closure(
2924
+ self._current_revision, profile=tech.pin()),
2925
+ }
2926
+
2927
+ def testbench_report(self, testbench):
2928
+ """Describe one testbench block with freshness and pins.
2929
+
2930
+ Args:
2931
+ testbench: Testbench entity id.
2932
+
2933
+ Returns:
2934
+ Record with freshness, pins, digest, and I28 pin.
2935
+
2936
+ Raises:
2937
+ ProjectError: unknown_entity for a missing block.
2938
+ """
2939
+ record = _testbench.describe_block(self, testbench)
2940
+ record["current_revision"] = self._current_revision
2941
+ return record