@ybotok/loam 0.2.0-alpha.5 → 0.2.0-alpha.6

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 (273) hide show
  1. package/CHANGELOG.md +901 -0
  2. package/MIGRATING-from-OpenSpec.md +7 -4
  3. package/QUICKSTART.md +8 -3
  4. package/README.md +13 -7
  5. package/ROADMAP.md +264 -11
  6. package/SCHEMA.md +439 -38
  7. package/WORKFLOW.md +23 -10
  8. package/dist/commands/adopt/render.js +47 -8
  9. package/dist/commands/adopt/render.js.map +1 -1
  10. package/dist/commands/archive/plan/landscape.js +45 -1
  11. package/dist/commands/archive/plan/landscape.js.map +1 -1
  12. package/dist/commands/archive/plan/model/extending.js +85 -0
  13. package/dist/commands/archive/plan/model/extending.js.map +1 -0
  14. package/dist/commands/archive/run.js +5 -2
  15. package/dist/commands/archive/run.js.map +1 -1
  16. package/dist/commands/context/print.js +11 -1
  17. package/dist/commands/context/print.js.map +1 -1
  18. package/dist/commands/doctor.js +46 -0
  19. package/dist/commands/doctor.js.map +1 -1
  20. package/dist/commands/explore.js +12 -2
  21. package/dist/commands/explore.js.map +1 -1
  22. package/dist/commands/list/views.js +5 -1
  23. package/dist/commands/list/views.js.map +1 -1
  24. package/dist/commands/seed/plan.js +97 -3
  25. package/dist/commands/seed/plan.js.map +1 -1
  26. package/dist/commands/seed/seed.js +7 -2
  27. package/dist/commands/seed/seed.js.map +1 -1
  28. package/dist/commands/show/feature.js +2 -1
  29. package/dist/commands/show/feature.js.map +1 -1
  30. package/dist/commands/show/marks.js +3 -5
  31. package/dist/commands/show/marks.js.map +1 -1
  32. package/dist/commands/show/service.js +39 -18
  33. package/dist/commands/show/service.js.map +1 -1
  34. package/dist/commands/subsystem/sync.js +128 -74
  35. package/dist/commands/subsystem/sync.js.map +1 -1
  36. package/dist/commands/subsystem/txn/exclude.js +132 -0
  37. package/dist/commands/subsystem/txn/exclude.js.map +1 -0
  38. package/dist/commands/subsystem/txn/report.js +124 -0
  39. package/dist/commands/subsystem/txn/report.js.map +1 -0
  40. package/dist/commands/subsystem/txn/txn.js +10 -3
  41. package/dist/commands/subsystem/txn/txn.js.map +1 -1
  42. package/dist/commands/subsystem/txn/views.js +113 -16
  43. package/dist/commands/subsystem/txn/views.js.map +1 -1
  44. package/dist/commands/validate/arch-coverage.js +16 -9
  45. package/dist/commands/validate/arch-coverage.js.map +1 -1
  46. package/dist/commands/validate/checks/fleet-shape.js +30 -77
  47. package/dist/commands/validate/checks/fleet-shape.js.map +1 -1
  48. package/dist/commands/validate/checks/vocabulary.js +4 -19
  49. package/dist/commands/validate/checks/vocabulary.js.map +1 -1
  50. package/dist/commands/validate/feature.js +1 -1
  51. package/dist/commands/validate/feature.js.map +1 -1
  52. package/dist/commands/validate/fleet/census.js +11 -10
  53. package/dist/commands/validate/fleet/census.js.map +1 -1
  54. package/dist/commands/validate/fleet/landscape.js +127 -119
  55. package/dist/commands/validate/fleet/landscape.js.map +1 -1
  56. package/dist/commands/validate/fleet/load.js +66 -0
  57. package/dist/commands/validate/fleet/load.js.map +1 -1
  58. package/dist/commands/validate/fleet/map/attest.js +103 -0
  59. package/dist/commands/validate/fleet/map/attest.js.map +1 -0
  60. package/dist/commands/validate/fleet/map/bindings.js +96 -0
  61. package/dist/commands/validate/fleet/map/bindings.js.map +1 -0
  62. package/dist/commands/validate/fleet/map/consumers.js +298 -0
  63. package/dist/commands/validate/fleet/map/consumers.js.map +1 -0
  64. package/dist/commands/validate/fleet/map/isolation.js +116 -0
  65. package/dist/commands/validate/fleet/map/isolation.js.map +1 -0
  66. package/dist/commands/validate/fleet/map/usecases.js +56 -0
  67. package/dist/commands/validate/fleet/map/usecases.js.map +1 -0
  68. package/dist/commands/validate/fleet/usecases/steps.js +49 -17
  69. package/dist/commands/validate/fleet/usecases/steps.js.map +1 -1
  70. package/dist/commands/validate/fleet/views/fleet-project.js +217 -0
  71. package/dist/commands/validate/fleet/views/fleet-project.js.map +1 -0
  72. package/dist/commands/validate/fleet/views/ids.js +13 -9
  73. package/dist/commands/validate/fleet/views/ids.js.map +1 -1
  74. package/dist/commands/validate/fleet/views/projects.js +258 -70
  75. package/dist/commands/validate/fleet/views/projects.js.map +1 -1
  76. package/dist/commands/validate/fleet/views/root-documents.js +97 -0
  77. package/dist/commands/validate/fleet/views/root-documents.js.map +1 -0
  78. package/dist/commands/validate/fleet/views/stale.js +2 -2
  79. package/dist/commands/validate/fleet/views/stale.js.map +1 -1
  80. package/dist/commands/validate/service/model/diverged.js +226 -0
  81. package/dist/commands/validate/service/model/diverged.js.map +1 -0
  82. package/dist/commands/validate/service/model/grade.js +163 -0
  83. package/dist/commands/validate/service/model/grade.js.map +1 -0
  84. package/dist/commands/validate/service/service.js +110 -34
  85. package/dist/commands/validate/service/service.js.map +1 -1
  86. package/dist/commands/validate/service/specs.js +39 -5
  87. package/dist/commands/validate/service/specs.js.map +1 -1
  88. package/dist/commands/validate/service/spine.js +41 -3
  89. package/dist/commands/validate/service/spine.js.map +1 -1
  90. package/dist/commands/validate/service/usecases/tags.js +113 -0
  91. package/dist/commands/validate/service/usecases/tags.js.map +1 -0
  92. package/dist/commands/validate/service/usecases/usecases.js +209 -0
  93. package/dist/commands/validate/service/usecases/usecases.js.map +1 -0
  94. package/dist/commands/validate/validate.js +10 -24
  95. package/dist/commands/validate/validate.js.map +1 -1
  96. package/dist/core/agent/agents-md/artifacts.js +1 -0
  97. package/dist/core/agent/agents-md/artifacts.js.map +1 -1
  98. package/dist/core/agent/agents-md/command-map.js +18 -4
  99. package/dist/core/agent/agents-md/command-map.js.map +1 -1
  100. package/dist/core/agent/agents-md/cycle.js +24 -9
  101. package/dist/core/agent/agents-md/cycle.js.map +1 -1
  102. package/dist/core/agent/agents-md/map/lenses/context.js +4 -3
  103. package/dist/core/agent/agents-md/map/lenses/context.js.map +1 -1
  104. package/dist/core/agent/agents-md/map/subsystem.js +51 -11
  105. package/dist/core/agent/agents-md/map/subsystem.js.map +1 -1
  106. package/dist/core/agent/agents-md/map/usecases.js +45 -3
  107. package/dist/core/agent/agents-md/map/usecases.js.map +1 -1
  108. package/dist/core/agent/agents-md.js +14 -4
  109. package/dist/core/agent/agents-md.js.map +1 -1
  110. package/dist/core/agent/support/report.js +39 -5
  111. package/dist/core/agent/support/report.js.map +1 -1
  112. package/dist/core/agent/workflows/adopt.js +123 -14
  113. package/dist/core/agent/workflows/adopt.js.map +1 -1
  114. package/dist/core/agent/workflows/check.js +35 -20
  115. package/dist/core/agent/workflows/check.js.map +1 -1
  116. package/dist/core/agent/workflows/closing.js +3 -1
  117. package/dist/core/agent/workflows/closing.js.map +1 -1
  118. package/dist/core/brief/brief.js +63 -11
  119. package/dist/core/brief/brief.js.map +1 -1
  120. package/dist/core/brief/checks.js +133 -7
  121. package/dist/core/brief/checks.js.map +1 -1
  122. package/dist/core/brief/landscape.js +212 -129
  123. package/dist/core/brief/landscape.js.map +1 -1
  124. package/dist/core/brief/map/extend.js +81 -0
  125. package/dist/core/brief/map/extend.js.map +1 -0
  126. package/dist/core/brief/map/owed.js +284 -0
  127. package/dist/core/brief/map/owed.js.map +1 -0
  128. package/dist/core/brief/targets.js +30 -29
  129. package/dist/core/brief/targets.js.map +1 -1
  130. package/dist/core/brief/unchecked.js +12 -2
  131. package/dist/core/brief/unchecked.js.map +1 -1
  132. package/dist/core/c4/likec4.js +23 -1
  133. package/dist/core/c4/likec4.js.map +1 -1
  134. package/dist/core/c4/parsed/deployment.js +5 -3
  135. package/dist/core/c4/parsed/deployment.js.map +1 -1
  136. package/dist/core/c4/parsed/view-ids.js +39 -1
  137. package/dist/core/c4/parsed/view-ids.js.map +1 -1
  138. package/dist/core/c4/prefetch/fleet.js +142 -0
  139. package/dist/core/c4/prefetch/fleet.js.map +1 -0
  140. package/dist/core/c4/project/architecture.js +52 -4
  141. package/dist/core/c4/project/architecture.js.map +1 -1
  142. package/dist/core/c4/project/batch.js +140 -0
  143. package/dist/core/c4/project/batch.js.map +1 -0
  144. package/dist/core/c4/project/documents.js +9 -4
  145. package/dist/core/c4/project/documents.js.map +1 -1
  146. package/dist/core/c4/project/load.js +77 -38
  147. package/dist/core/c4/project/load.js.map +1 -1
  148. package/dist/core/c4/project/staged.js +46 -12
  149. package/dist/core/c4/project/staged.js.map +1 -1
  150. package/dist/core/c4/resolve/attested.js +0 -0
  151. package/dist/core/c4/resolve/attested.js.map +1 -0
  152. package/dist/core/c4/resolve/service.js +8 -17
  153. package/dist/core/c4/resolve/service.js.map +1 -1
  154. package/dist/core/c4/resolve/steps.js +18 -0
  155. package/dist/core/c4/resolve/steps.js.map +1 -1
  156. package/dist/core/c4/root-project/exclude.js +352 -0
  157. package/dist/core/c4/root-project/exclude.js.map +1 -0
  158. package/dist/core/c4/service-model/documents.js +47 -0
  159. package/dist/core/c4/service-model/documents.js.map +1 -0
  160. package/dist/core/c4/service-model/fleet/extending.js +82 -0
  161. package/dist/core/c4/service-model/fleet/extending.js.map +1 -0
  162. package/dist/core/c4/service-model/load.js +165 -0
  163. package/dist/core/c4/service-model/load.js.map +1 -0
  164. package/dist/core/c4/service-model/renderer.js +128 -0
  165. package/dist/core/c4/service-model/renderer.js.map +1 -0
  166. package/dist/core/c4/service-model/shape.js +126 -0
  167. package/dist/core/c4/service-model/shape.js.map +1 -0
  168. package/dist/core/c4/service-model/slice.js +188 -0
  169. package/dist/core/c4/service-model/slice.js.map +1 -0
  170. package/dist/core/c4/source-scan.js +26 -0
  171. package/dist/core/c4/source-scan.js.map +1 -1
  172. package/dist/core/c4/splice/authored-source.js +60 -13
  173. package/dist/core/c4/splice/authored-source.js.map +1 -1
  174. package/dist/core/c4/splice/contract.js.map +1 -1
  175. package/dist/core/c4/splice/identity/declared.js +87 -0
  176. package/dist/core/c4/splice/identity/declared.js.map +1 -0
  177. package/dist/core/c4/splice/identity/edges.js +17 -0
  178. package/dist/core/c4/splice/identity/edges.js.map +1 -1
  179. package/dist/core/c4/splice/identity/existing.js +80 -0
  180. package/dist/core/c4/splice/identity/existing.js.map +1 -0
  181. package/dist/core/c4/splice/identity/interior.js +80 -0
  182. package/dist/core/c4/splice/identity/interior.js.map +1 -0
  183. package/dist/core/c4/splice/identity/owner.js +50 -0
  184. package/dist/core/c4/splice/identity/owner.js.map +1 -0
  185. package/dist/core/c4/splice/landscape-merge.js +154 -100
  186. package/dist/core/c4/splice/landscape-merge.js.map +1 -1
  187. package/dist/core/c4/splice/model/merge.js +140 -0
  188. package/dist/core/c4/splice/model/merge.js.map +1 -0
  189. package/dist/core/c4/splice/placement.js +14 -4
  190. package/dist/core/c4/splice/placement.js.map +1 -1
  191. package/dist/core/c4/workspace.js +2 -1
  192. package/dist/core/c4/workspace.js.map +1 -1
  193. package/dist/core/coherence/coherence.js +7 -0
  194. package/dist/core/coherence/coherence.js.map +1 -1
  195. package/dist/core/delta/delta.js +9 -0
  196. package/dist/core/delta/delta.js.map +1 -1
  197. package/dist/core/deployment/delta.js +4 -0
  198. package/dist/core/deployment/delta.js.map +1 -1
  199. package/dist/core/diff/base-state.js +21 -5
  200. package/dist/core/diff/base-state.js.map +1 -1
  201. package/dist/core/diff/victims.js +10 -1
  202. package/dist/core/diff/victims.js.map +1 -1
  203. package/dist/core/docs.js +29 -24
  204. package/dist/core/docs.js.map +1 -1
  205. package/dist/core/doctor/config.js +7 -3
  206. package/dist/core/doctor/config.js.map +1 -1
  207. package/dist/core/doctor/doctor.js +36 -8
  208. package/dist/core/doctor/doctor.js.map +1 -1
  209. package/dist/core/doctor/report.js.map +1 -1
  210. package/dist/core/doctor/reports/scan.js +242 -0
  211. package/dist/core/doctor/reports/scan.js.map +1 -0
  212. package/dist/core/explain/refusals.js +1 -1
  213. package/dist/core/explain/refusals.js.map +1 -1
  214. package/dist/core/explore/explore.js +44 -9
  215. package/dist/core/explore/explore.js.map +1 -1
  216. package/dist/core/fleet-context.js +47 -0
  217. package/dist/core/fleet-context.js.map +1 -1
  218. package/dist/core/gate/partners.js +5 -9
  219. package/dist/core/gate/partners.js.map +1 -1
  220. package/dist/core/kernel/ids/fqn/ancestors.js +65 -0
  221. package/dist/core/kernel/ids/fqn/ancestors.js.map +1 -0
  222. package/dist/core/pack/living.js +47 -13
  223. package/dist/core/pack/living.js.map +1 -1
  224. package/dist/core/pack/pack.js +9 -6
  225. package/dist/core/pack/pack.js.map +1 -1
  226. package/dist/core/projection/arch-slice.js +2 -2
  227. package/dist/core/projection/arch-slice.js.map +1 -1
  228. package/dist/core/repo/paths.js +11 -9
  229. package/dist/core/repo/paths.js.map +1 -1
  230. package/dist/core/repo/tree/render/projects.js +93 -35
  231. package/dist/core/repo/tree/render/projects.js.map +1 -1
  232. package/dist/core/repo/tree/render/stale.js +2 -3
  233. package/dist/core/repo/tree/render/stale.js.map +1 -1
  234. package/dist/core/repo/tree/render/views.js +51 -3
  235. package/dist/core/repo/tree/render/views.js.map +1 -1
  236. package/dist/core/repo/tree/walk.js +45 -2
  237. package/dist/core/repo/tree/walk.js.map +1 -1
  238. package/dist/core/scaffold/landscape.js +219 -9
  239. package/dist/core/scaffold/landscape.js.map +1 -1
  240. package/dist/core/staging/writes.js.map +1 -1
  241. package/dist/core/usecases/delta/claims.js +1 -0
  242. package/dist/core/usecases/delta/claims.js.map +1 -1
  243. package/dist/core/usecases/delta/overlay.js +4 -0
  244. package/dist/core/usecases/delta/overlay.js.map +1 -1
  245. package/dist/core/usecases/fleet.js +18 -6
  246. package/dist/core/usecases/fleet.js.map +1 -1
  247. package/dist/core/usecases/operations.js +7 -0
  248. package/dist/core/usecases/operations.js.map +1 -1
  249. package/dist/core/usecases/service/exclude.js +119 -0
  250. package/dist/core/usecases/service/exclude.js.map +1 -0
  251. package/dist/core/usecases/service/flows.js +161 -0
  252. package/dist/core/usecases/service/flows.js.map +1 -0
  253. package/dist/core/vocabulary/maturity.js +10 -0
  254. package/dist/core/vocabulary/maturity.js.map +1 -1
  255. package/examples/README.md +24 -6
  256. package/examples/docs/architecture/landscape.likec4 +41 -0
  257. package/examples/docs/architecture/usecases/order-notification.likec4 +5 -2
  258. package/examples/docs/likec4.config.json +0 -1
  259. package/examples/docs/services/checkout-web/model.likec4 +39 -56
  260. package/examples/docs/services/order-service/arch.spec.md +13 -6
  261. package/examples/docs/services/order-service/model.likec4 +53 -67
  262. package/examples/docs/services/payment-service/arch.spec.md +7 -4
  263. package/examples/docs/services/payment-service/model.likec4 +32 -35
  264. package/examples/docs/services/platform/identity-service/arch.spec.md +7 -4
  265. package/examples/docs/services/platform/identity-service/model.likec4 +29 -33
  266. package/examples/docs/services/platform/notification-service/health.yaml +8 -3
  267. package/examples/docs/services/platform/notification-service/model.likec4 +39 -59
  268. package/package.json +1 -1
  269. package/examples/docs/services/checkout-web/likec4.config.json +0 -4
  270. package/examples/docs/services/order-service/likec4.config.json +0 -4
  271. package/examples/docs/services/payment-service/likec4.config.json +0 -4
  272. package/examples/docs/services/platform/identity-service/likec4.config.json +0 -4
  273. package/examples/docs/services/platform/notification-service/likec4.config.json +0 -4
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Who consumes a drawn element — and the datastore grades that read the answer.
3
+ *
4
+ * The census used to walk the MAP's relationships alone, and under the extending
5
+ * shape that is a partial view of the fleet: a service draws the container-level
6
+ * call in its own `model.likec4` and the map carries the service-level twin, so a
7
+ * store whose consuming edges were all drawn in models had ZERO consumers and
8
+ * earned nothing at all, while a store reached by three models and one map edge
9
+ * was reported `landscape.datastore-private` naming the one service the map
10
+ * happened to draw (R1). A model is evidence about the fleet exactly as the map
11
+ * is, so both are counted here, once, by one derivation two checks share.
12
+ *
13
+ * WHERE A PRIVATE STORE LIVES, stated once because three surfaces used to answer
14
+ * it differently: a store one service owns is NESTED UNDER THAT SERVICE'S
15
+ * ELEMENT — in the service's extending model (`extend <fqn> { store = database
16
+ * … }`), or inside the service's element on the map when the map draws that
17
+ * service's containers. A second service reaching it later is drawn where that
18
+ * service draws every other call: a container-level edge in ITS model naming
19
+ * `<owner-fqn>.<store>`, carried up to the map as a service-level edge to the
20
+ * owning service. Nothing moves back, and nothing goes ungraded — a nested store
21
+ * two services reach is `landscape.datastore-shared` here (E3).
22
+ *
23
+ * WHICH OF THE TWO PLACEMENTS, and this is measured rather than assumed: a
24
+ * per-service project is `architecture/` plus that ONE model, so an edge naming
25
+ * `<owner-fqn>.<store>` resolves for the second service only when the MAP
26
+ * declares the store. Written in the owner's `extend` block instead, the same
27
+ * edge is two errors on the consumer's own model ("Could not resolve reference
28
+ * to Referenceable named 'store'"), so that store is private for good and its
29
+ * second consumer can only ever be drawn at service level. The message below
30
+ * says so; SCHEMA carries the same sentence.
31
+ *
32
+ * The choice is cheap, and both messages now say exactly HOW cheap, because
33
+ * "reversible" was false in the one state that used to assert it. The two
34
+ * placements do give the store the same id, `<owner-fqn>.<store>` — but the
35
+ * move is only free in ONE direction once a second service names it. Measured
36
+ * on `examples/docs`: moving `db` out of order-service's `extend` block into
37
+ * `orderService` on the map left the model at 12 elements · 8 relationships,
38
+ * left `Covers: marketplace.orderService.db` in its arch.spec.md resolving, and
39
+ * left checkout-web's container-level edge to it resolving too. Moving it BACK
40
+ * with that second edge drawn is two errors on checkout-web's own model
41
+ * ("Could not resolve reference to Referenceable named 'db'"), or
42
+ * `landscape.invalid` plus one `spine.landscape-invalid` per service when the
43
+ * second edge is drawn on the map — because an `extend` block is visible to its
44
+ * own service's project alone. `datastore-shared` printed the unconditional
45
+ * clause in 100% of the states it fires in, every one of which already has the
46
+ * second consumer that makes map→extend break (verification 2026-09-04). So the
47
+ * rule both messages now state is directional: extend→map is always safe;
48
+ * map→extend only while no other service's model or map edge names the store.
49
+ *
50
+ * And the remedy is named for the consumer's own model SHAPE rather than
51
+ * assumed: `extend <fqn>` in a STANDALONE model (one that declares its own
52
+ * `specification`) resolves nothing, because such a model is parsed alone — so
53
+ * prescribing it there turns a clean run into `c4.invalid`. The shape comes off
54
+ * the same `./attest.ts` record the consumer join reads.
55
+ *
56
+ * A sub-package of `fleet/` for `./isolation.ts`'s reason and with its rule: it
57
+ * imports NOTHING from `fleet/`, so the map facts it reads arrive on the input
58
+ * records the caller fills.
59
+ */
60
+ import {} from "../../../../core/c4/likec4.js";
61
+ import {} from "../../../../core/vocabulary/report.js";
62
+ import {} from "./attest.js";
63
+ /** The element kind read as a datastore, compared case-insensitively. */
64
+ const DATASTORE_KIND = "database";
65
+ /**
66
+ * Distinct consumer SERVICES of an element, its nested children included: an
67
+ * edge into `kafka.paymentEvents` is consumption of `kafka`.
68
+ *
69
+ * Two kinds of evidence, unioned. From the MAP: a relationship whose target is
70
+ * the element or a descendant and whose source the shared resolver files under a
71
+ * real `services/<id>/` — persons, other external systems and unresolved sources
72
+ * never count, and two edges from one service are one consumer. From the
73
+ * MODELS: a service whose own model attests an OUTBOUND call at the element or a
74
+ * descendant.
75
+ *
76
+ * The model half reads EXTENDING models only, and the reason is the id space. An
77
+ * extending model's ids are the map's fully-qualified ids — one project — so the
78
+ * join is exact. A standalone model's ids are its own file's, and
79
+ * `core/c4/resolve/attested.ts` says outright that a counterpart is never
80
+ * matched to a landscape element: joining one would file a coincidence of
81
+ * spelling as a coupling between two services.
82
+ */
83
+ export function consumerCensus(input) {
84
+ const reaches = (elementId, id) => id === elementId || id.startsWith(`${elementId}.`);
85
+ return (elementId) => {
86
+ const out = new Set();
87
+ for (const r of input.relationships) {
88
+ if (!reaches(elementId, r.target))
89
+ continue;
90
+ const svc = input.resolve(r.source);
91
+ if (input.services.has(svc))
92
+ out.add(svc);
93
+ }
94
+ for (const model of input.attested) {
95
+ if (!model.extending)
96
+ continue;
97
+ if (model.calls.some((c) => c.direction === "out" && reaches(elementId, c.counterpartId))) {
98
+ out.add(model.service);
99
+ }
100
+ }
101
+ return [...out].sort();
102
+ };
103
+ }
104
+ /**
105
+ * A datastore drawn as a fleet-level peer makes a claim its consumer count
106
+ * either supports or refutes: one consumer means the drawing is false — the
107
+ * store is that service's internals, not a system in its own right — while two
108
+ * or more mean the strongest coupling two services can have, which deserves to
109
+ * be stated rather than inferred. A NESTED store is private by construction, so
110
+ * it is silent at one consumer and earns the same `datastore-shared` at two.
111
+ */
112
+ export function datastoreFindings(input) {
113
+ const findings = [];
114
+ for (const e of input.drawn) {
115
+ // An element that stands for a real service is the fleet itself, not its
116
+ // shape; a datastore bound to a directory is graded by the binding checks.
117
+ if (input.standsForService(e))
118
+ continue;
119
+ if (e.kind.toLowerCase() !== DATASTORE_KIND)
120
+ continue;
121
+ const consumers = input.consumers(e.id);
122
+ if (consumers.length === 0)
123
+ continue;
124
+ findings.push(consumers.length === 1 ? privateFinding(e, consumers[0], input) : sharedFinding(e.title, consumers, "", ""));
125
+ }
126
+ for (const store of nestedStoresOf(input)) {
127
+ // The owner is a consumer by construction — the store sits inside the element
128
+ // that resolves to it — so silence at one consumer IS the fixed state, and
129
+ // the only question left is whether a second service reaches the same data.
130
+ const consumers = [...new Set([store.owner, ...input.consumers(store.fqn)])].sort();
131
+ if (consumers.length < 2)
132
+ continue;
133
+ findings.push(sharedFinding(store.title, consumers,
134
+ // The store's own id, and said as such. The clause read "nested under
135
+ // '<fqn>'", which sends a reader looking for an element that HOLDS the
136
+ // store — when that id IS the store, and its container is its prefix.
137
+ ` — ${store.owner}'s own store, written as '${store.fqn}'`, reversibility(store, consumers.filter((c) => c !== store.owner))));
138
+ }
139
+ return findings;
140
+ }
141
+ /**
142
+ * Every store nested under the element that resolves to its owner, from both
143
+ * places the stance allows one to be written: inside the service's element ON
144
+ * THE MAP, and in the service's own extending model. Keyed by fqn, so a
145
+ * map-declared store — which the owner's model slice carries too, since a slice
146
+ * is everything resolving to the service — is one store and not two.
147
+ *
148
+ * Both sources are needed and neither is redundant. The map's covers a service
149
+ * whose model is standalone or absent (no slice to read); the model's covers a
150
+ * store the map never draws, which is the placement the adopt brief prescribes.
151
+ */
152
+ function nestedStoresOf(input) {
153
+ const drawnIds = new Set(input.drawn.map((e) => e.id));
154
+ const byFqn = new Map();
155
+ for (const e of input.elements) {
156
+ if (e.kind.toLowerCase() !== DATASTORE_KIND || drawnIds.has(e.id))
157
+ continue;
158
+ // The peer branch's guard, which this half was missing: a `database` bound
159
+ // with `metadata { service '<id>' }` and drawn one level down became a store
160
+ // "owned" by its own binding, so a second reader made it a shared datastore
161
+ // and the fix offered was to give each service its own copy of a service.
162
+ if (input.standsForService(e))
163
+ continue;
164
+ const owner = input.resolve(e.id);
165
+ if (!input.services.has(owner))
166
+ continue;
167
+ byFqn.set(e.id, { owner, fqn: e.id, title: e.title });
168
+ }
169
+ for (const store of input.nestedStores)
170
+ if (!byFqn.has(store.fqn))
171
+ byFqn.set(store.fqn, store);
172
+ return [...byFqn.values()];
173
+ }
174
+ /**
175
+ * Which way the store may still be moved, now that a second service reaches it.
176
+ *
177
+ * The clause this replaces said the two placements were interchangeable — "the
178
+ * two placements give it the same id … so moving the declaration between them
179
+ * rewrites no edge" — and that is false in every state `datastore-shared` fires
180
+ * in, because the finding needs a second consumer and a second consumer's edge
181
+ * resolves only against a store the MAP declares (a per-service project is
182
+ * `architecture/` plus one model). Measured: performing the move the sentence
183
+ * called free turned a clean `examples/docs` into `c4.invalid` on the consumer's
184
+ * model, or into `landscape.invalid` + one `spine.landscape-invalid` per service
185
+ * when the second edge is on the map (verification 2026-09-04).
186
+ *
187
+ * The direction is stated rather than the current placement, and it holds
188
+ * either way round: out of the owner's `extend` block onto the map is always
189
+ * safe, back in is not while another service names the store. (In practice a
190
+ * store this finding reaches is one the MAP declares — a second consumer's edge
191
+ * cannot resolve against an `extend` block, so it could never have been drawn
192
+ * — which is the same fact said from the other side.)
193
+ */
194
+ function reversibility(store, others) {
195
+ const named = others.length === 1 ? `${others[0]}'s edge` : `${others.join(", ")}'s edges`;
196
+ return (`. Both placements give it the same id, ${store.fqn}, but with a second consumer the move is ONE-WAY: ` +
197
+ `${named} resolves only against a declaration the MAP holds — a per-service project is \`architecture/\` ` +
198
+ `plus one model, so an \`extend\` block is visible to its own service's project alone. Out of ` +
199
+ `${store.owner}'s \`extend\` block onto ${store.owner}'s element on the map is always safe; moving it ` +
200
+ `back INTO the \`extend\` block breaks that edge — \`c4.invalid\` on the other service's model, or ` +
201
+ `\`landscape.invalid\` when the edge is drawn on the map`);
202
+ }
203
+ /**
204
+ * Where the store goes, named for the consumer's own model SHAPE — which this
205
+ * sentence used to assert instead of reading. It always prescribed the
206
+ * extending-shape remedy, and following it on a standalone consumer turns a
207
+ * clean run into `c4.invalid`: a model that declares its own `specification` is
208
+ * parsed alone, so `extend <fqn>` there resolves nothing at all (measured — one
209
+ * error, "Could not resolve reference to Element named '<fqn>'"). The two
210
+ * placements otherwise give the store the SAME id, which is the fact that makes
211
+ * the choice cheap: measured on examples/docs, moving `db` out of
212
+ * order-service's extend block into `orderService` on the map left the model at
213
+ * 12 elements · 8 relationships, left `Covers: marketplace.orderService.db` in
214
+ * its arch.spec.md resolving, and left checkout-web's edge to it resolving too.
215
+ */
216
+ function placement(at) {
217
+ // Direction, not symmetry. The clause used to read "moving the declaration
218
+ // between them later rewrites no edge", which is true only while this store
219
+ // has ONE consumer — the state this finding is in — and false the moment the
220
+ // second consumer it goes on to promise arrives: that consumer's edge resolves
221
+ // against the map's declaration alone. Saying it unconditionally here is what
222
+ // made `datastore-shared` inherit a sentence that was false in every state it
223
+ // printed in (verification 2026-09-04).
224
+ const same = `the two placements give it the same id, ${at.fqn}.${at.local}, and with one consumer either move ` +
225
+ `is free — out of the \`extend\` block onto the map always is, and back into it while no other ` +
226
+ `service's model or map edge names the store, an \`extend\` block being visible to its own service's ` +
227
+ `project alone`;
228
+ if (at.shape === "extending") {
229
+ return (`A store one service owns is nested UNDER that service's element: write it in the ` +
230
+ `\`extend ${at.fqn} { … }\` block of ${at.path}, or inside '${at.fqn}' here when the map draws ` +
231
+ `that service's containers, and delete this peer — ${same}.`);
232
+ }
233
+ // The two absent-extending-model arms differ in what there is to do: a
234
+ // STANDALONE model exists and can be migrated; with no model at all there is
235
+ // nothing to migrate, and "migrate the model" sent a reader looking for a file
236
+ // loam had just said it could not find (verification 2026-09-04).
237
+ const otherwise = at.shape === "standalone"
238
+ ? `${at.path} declares its own \`specification\`, so it is parsed alone and \`extend ${at.fqn}\` ` +
239
+ `resolves nothing there`
240
+ : `loam read no extending model at ${at.path}`;
241
+ const migrate = at.shape === "standalone"
242
+ ? `migrate the model to the extending shape (SCHEMA.md, "Two shapes of a service model")`
243
+ : `write an extending model at ${at.path} (SCHEMA.md, "Two shapes of a service model")`;
244
+ return (`A store one service owns is nested UNDER that service's element, and this consumer has no ` +
245
+ `extending model to nest it in (${otherwise}): write it inside '${at.fqn}' here, where the map draws ` +
246
+ `that service's containers, and delete this peer — or ${migrate} and write it in an ` +
247
+ `\`extend ${at.fqn} { … }\` block there, where ${same}.`);
248
+ }
249
+ /**
250
+ * The one-consumer message, and it names the PLACEMENT rather than a directory:
251
+ * the remedy used to read "move it into services/<id>/model.likec4 as a nested
252
+ * container, or add the second consumer's edge", which offered two remedies as
253
+ * equals and left the second one impossible — an edge naming the moved store is
254
+ * `landscape.invalid` for loam, so an author who took the first remedy believed
255
+ * they had shut the second door (#01). They had not, and the sentence now says
256
+ * how: a later consumer is drawn in ITS OWN model, and loam counts that — as
257
+ * long as that model EXTENDS the map, which the caveat below is careful to say.
258
+ */
259
+ function privateFinding(store, consumer, input) {
260
+ const fqn = input.drawn.find((e) => input.resolve(e.id) === consumer)?.id ?? consumer;
261
+ const local = store.id.split(".").at(-1) ?? store.id;
262
+ const model = input.attested.find((m) => m.service === consumer);
263
+ const shape = model === undefined ? null : model.extending ? "extending" : "standalone";
264
+ return {
265
+ severity: "warn",
266
+ code: "landscape.datastore-private",
267
+ subject: store.title,
268
+ message: `landscape: '${store.title}' is a datastore with a single consumer at fleet level ` +
269
+ `('${consumer}') — drawn as a peer it reads as a system in its own right, available to be ` +
270
+ `depended on. ${placement({ fqn, local, path: `${input.pathOf(consumer)}/model.likec4`, shape })} ` +
271
+ `Nesting closes no door: a second service reaching the same data later draws a container-level ` +
272
+ `edge to ${fqn}.${local} in ITS OWN model plus a service-level edge to '${fqn}' here, and loam ` +
273
+ `counts a consumer an EXTENDING model attests exactly as it counts one drawn here — so that ` +
274
+ `state is \`landscape.datastore-shared\`, never silence. A consumer whose model stands alone is ` +
275
+ `not counted (its ids are its own file's, never the map's), so its edge belongs on the map. ` +
276
+ `Which placement: that edge RESOLVES only against a store the map declares (a per-service ` +
277
+ `project is architecture/ plus one model), so ` +
278
+ (shape === "extending"
279
+ ? `nest it here when a second consumer is foreseeable and in the model when it is not. `
280
+ : `the map is the placement that keeps that door open. `) +
281
+ `If another service reaches the same data today, draw its edge and this becomes that warning now`,
282
+ };
283
+ }
284
+ /** The two-or-more message, shared by the peer and the nested store — one word about coupling, once. */
285
+ function sharedFinding(title, consumers, where, tail) {
286
+ return {
287
+ severity: "warn",
288
+ code: "landscape.datastore-shared",
289
+ subject: title,
290
+ message: `landscape: '${title}' is a datastore shared by ${consumers.length} services ` +
291
+ `(${consumers.join(", ")})${where} — the strongest coupling two services can have, and the ` +
292
+ `hardest to undo. Shared means the same DATA: if they read the same tables or keys, keep it ` +
293
+ `where it is and let this warning state the coupling; if they only share a host or cluster ` +
294
+ `(two schemas, two lock paths), that is operational blast radius — a runbook fact — and the ` +
295
+ `honest model is one private store per service${tail}`,
296
+ };
297
+ }
298
+ //# sourceMappingURL=consumers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consumers.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/map/consumers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Who consumes a drawn element — and the datastore grades that read the answer.\n *\n * The census used to walk the MAP's relationships alone, and under the extending\n * shape that is a partial view of the fleet: a service draws the container-level\n * call in its own `model.likec4` and the map carries the service-level twin, so a\n * store whose consuming edges were all drawn in models had ZERO consumers and\n * earned nothing at all, while a store reached by three models and one map edge\n * was reported `landscape.datastore-private` naming the one service the map\n * happened to draw (R1). A model is evidence about the fleet exactly as the map\n * is, so both are counted here, once, by one derivation two checks share.\n *\n * WHERE A PRIVATE STORE LIVES, stated once because three surfaces used to answer\n * it differently: a store one service owns is NESTED UNDER THAT SERVICE'S\n * ELEMENT — in the service's extending model (`extend <fqn> { store = database\n * … }`), or inside the service's element on the map when the map draws that\n * service's containers. A second service reaching it later is drawn where that\n * service draws every other call: a container-level edge in ITS model naming\n * `<owner-fqn>.<store>`, carried up to the map as a service-level edge to the\n * owning service. Nothing moves back, and nothing goes ungraded — a nested store\n * two services reach is `landscape.datastore-shared` here (E3).\n *\n * WHICH OF THE TWO PLACEMENTS, and this is measured rather than assumed: a\n * per-service project is `architecture/` plus that ONE model, so an edge naming\n * `<owner-fqn>.<store>` resolves for the second service only when the MAP\n * declares the store. Written in the owner's `extend` block instead, the same\n * edge is two errors on the consumer's own model (\"Could not resolve reference\n * to Referenceable named 'store'\"), so that store is private for good and its\n * second consumer can only ever be drawn at service level. The message below\n * says so; SCHEMA carries the same sentence.\n *\n * The choice is cheap, and both messages now say exactly HOW cheap, because\n * \"reversible\" was false in the one state that used to assert it. The two\n * placements do give the store the same id, `<owner-fqn>.<store>` — but the\n * move is only free in ONE direction once a second service names it. Measured\n * on `examples/docs`: moving `db` out of order-service's `extend` block into\n * `orderService` on the map left the model at 12 elements · 8 relationships,\n * left `Covers: marketplace.orderService.db` in its arch.spec.md resolving, and\n * left checkout-web's container-level edge to it resolving too. Moving it BACK\n * with that second edge drawn is two errors on checkout-web's own model\n * (\"Could not resolve reference to Referenceable named 'db'\"), or\n * `landscape.invalid` plus one `spine.landscape-invalid` per service when the\n * second edge is drawn on the map — because an `extend` block is visible to its\n * own service's project alone. `datastore-shared` printed the unconditional\n * clause in 100% of the states it fires in, every one of which already has the\n * second consumer that makes map→extend break (verification 2026-09-04). So the\n * rule both messages now state is directional: extend→map is always safe;\n * map→extend only while no other service's model or map edge names the store.\n *\n * And the remedy is named for the consumer's own model SHAPE rather than\n * assumed: `extend <fqn>` in a STANDALONE model (one that declares its own\n * `specification`) resolves nothing, because such a model is parsed alone — so\n * prescribing it there turns a clean run into `c4.invalid`. The shape comes off\n * the same `./attest.ts` record the consumer join reads.\n *\n * A sub-package of `fleet/` for `./isolation.ts`'s reason and with its rule: it\n * imports NOTHING from `fleet/`, so the map facts it reads arrive on the input\n * records the caller fills.\n */\nimport { type Elem, type Rel } from \"../../../../core/c4/likec4.js\";\nimport { type Finding } from \"../../../../core/vocabulary/report.js\";\nimport { type AttestedModel, type NestedStore } from \"./attest.js\";\n\n/** The element kind read as a datastore, compared case-insensitively. */\nconst DATASTORE_KIND = \"database\";\n\nexport interface CensusInput {\n /** The map's own edges — a service-level twin is what the map carries of a container-level call. */\n relationships: Rel[];\n /** The services/<id>/ directories that exist: a consumer is a source filed under a real one. */\n services: ReadonlySet<string>;\n /** The shared element→service resolver every edge join uses. */\n resolve: (id: string) => string;\n /** What each service's own model attests (`./attest.ts`). */\n attested: readonly AttestedModel[];\n}\n\n/**\n * Distinct consumer SERVICES of an element, its nested children included: an\n * edge into `kafka.paymentEvents` is consumption of `kafka`.\n *\n * Two kinds of evidence, unioned. From the MAP: a relationship whose target is\n * the element or a descendant and whose source the shared resolver files under a\n * real `services/<id>/` — persons, other external systems and unresolved sources\n * never count, and two edges from one service are one consumer. From the\n * MODELS: a service whose own model attests an OUTBOUND call at the element or a\n * descendant.\n *\n * The model half reads EXTENDING models only, and the reason is the id space. An\n * extending model's ids are the map's fully-qualified ids — one project — so the\n * join is exact. A standalone model's ids are its own file's, and\n * `core/c4/resolve/attested.ts` says outright that a counterpart is never\n * matched to a landscape element: joining one would file a coincidence of\n * spelling as a coupling between two services.\n */\nexport function consumerCensus(input: CensusInput): (elementId: string) => string[] {\n const reaches = (elementId: string, id: string): boolean => id === elementId || id.startsWith(`${elementId}.`);\n return (elementId: string): string[] => {\n const out = new Set<string>();\n for (const r of input.relationships) {\n if (!reaches(elementId, r.target)) continue;\n const svc = input.resolve(r.source);\n if (input.services.has(svc)) out.add(svc);\n }\n for (const model of input.attested) {\n if (!model.extending) continue;\n if (model.calls.some((c) => c.direction === \"out\" && reaches(elementId, c.counterpartId))) {\n out.add(model.service);\n }\n }\n return [...out].sort();\n };\n}\n\n/** What loam actually read at a consumer's own `model.likec4`, which decides where a store may be nested. */\nexport type ConsumerShape = \"extending\" | \"standalone\" | null;\n\nexport interface DatastoreInput {\n /** Service-LEVEL elements — the fleet map's own boxes, where a store drawn as a peer is found. */\n drawn: Elem[];\n /** Every element the MAP declares, at any depth — the other place a nested store is written. */\n elements: Elem[];\n /** Stores an extending model declares inside its own element (`./attest.ts`). */\n nestedStores: readonly NestedStore[];\n /** The services/<id>/ directories that exist. */\n services: ReadonlySet<string>;\n /** The shared element→service resolver, for naming the consumer's own element on the map. */\n resolve: (id: string) => string;\n /**\n * `census.ts`'s one predicate for \"this element stands for a `services/<id>/`\",\n * injected because a `fleet/map/` module may not import `fleet/`. Both halves\n * of the grade below skip such an element: it is the fleet itself, not its\n * shape, and a store bound to a directory is the binding checks' subject.\n * `./attest.ts` applies the same predicate to the stores a MODEL declares, so\n * all three places answer it once.\n */\n standsForService: (e: Elem) => boolean;\n /** What each service's own model attests (`./attest.ts`) — the consumer join AND the shape the remedy names. */\n attested: readonly AttestedModel[];\n /** The census above, built once and shared with the platform-candidate advisory. */\n consumers: (elementId: string) => string[];\n /** Where a service's directory sits, repo-relative — the enumeration's answer, never `services/<id>`. */\n pathOf: (id: string) => string;\n}\n\n/**\n * A datastore drawn as a fleet-level peer makes a claim its consumer count\n * either supports or refutes: one consumer means the drawing is false — the\n * store is that service's internals, not a system in its own right — while two\n * or more mean the strongest coupling two services can have, which deserves to\n * be stated rather than inferred. A NESTED store is private by construction, so\n * it is silent at one consumer and earns the same `datastore-shared` at two.\n */\nexport function datastoreFindings(input: DatastoreInput): Finding[] {\n const findings: Finding[] = [];\n for (const e of input.drawn) {\n // An element that stands for a real service is the fleet itself, not its\n // shape; a datastore bound to a directory is graded by the binding checks.\n if (input.standsForService(e)) continue;\n if (e.kind.toLowerCase() !== DATASTORE_KIND) continue;\n const consumers = input.consumers(e.id);\n if (consumers.length === 0) continue;\n findings.push(\n consumers.length === 1 ? privateFinding(e, consumers[0]!, input) : sharedFinding(e.title, consumers, \"\", \"\"),\n );\n }\n for (const store of nestedStoresOf(input)) {\n // The owner is a consumer by construction — the store sits inside the element\n // that resolves to it — so silence at one consumer IS the fixed state, and\n // the only question left is whether a second service reaches the same data.\n const consumers = [...new Set([store.owner, ...input.consumers(store.fqn)])].sort();\n if (consumers.length < 2) continue;\n findings.push(\n sharedFinding(\n store.title,\n consumers,\n // The store's own id, and said as such. The clause read \"nested under\n // '<fqn>'\", which sends a reader looking for an element that HOLDS the\n // store — when that id IS the store, and its container is its prefix.\n ` — ${store.owner}'s own store, written as '${store.fqn}'`,\n reversibility(store, consumers.filter((c) => c !== store.owner)),\n ),\n );\n }\n\n return findings;\n}\n\n/**\n * Every store nested under the element that resolves to its owner, from both\n * places the stance allows one to be written: inside the service's element ON\n * THE MAP, and in the service's own extending model. Keyed by fqn, so a\n * map-declared store — which the owner's model slice carries too, since a slice\n * is everything resolving to the service — is one store and not two.\n *\n * Both sources are needed and neither is redundant. The map's covers a service\n * whose model is standalone or absent (no slice to read); the model's covers a\n * store the map never draws, which is the placement the adopt brief prescribes.\n */\nfunction nestedStoresOf(input: DatastoreInput): NestedStore[] {\n const drawnIds = new Set(input.drawn.map((e) => e.id));\n const byFqn = new Map<string, NestedStore>();\n for (const e of input.elements) {\n if (e.kind.toLowerCase() !== DATASTORE_KIND || drawnIds.has(e.id)) continue;\n // The peer branch's guard, which this half was missing: a `database` bound\n // with `metadata { service '<id>' }` and drawn one level down became a store\n // \"owned\" by its own binding, so a second reader made it a shared datastore\n // and the fix offered was to give each service its own copy of a service.\n if (input.standsForService(e)) continue;\n const owner = input.resolve(e.id);\n if (!input.services.has(owner)) continue;\n byFqn.set(e.id, { owner, fqn: e.id, title: e.title });\n }\n for (const store of input.nestedStores) if (!byFqn.has(store.fqn)) byFqn.set(store.fqn, store);\n return [...byFqn.values()];\n}\n\n/**\n * Which way the store may still be moved, now that a second service reaches it.\n *\n * The clause this replaces said the two placements were interchangeable — \"the\n * two placements give it the same id … so moving the declaration between them\n * rewrites no edge\" — and that is false in every state `datastore-shared` fires\n * in, because the finding needs a second consumer and a second consumer's edge\n * resolves only against a store the MAP declares (a per-service project is\n * `architecture/` plus one model). Measured: performing the move the sentence\n * called free turned a clean `examples/docs` into `c4.invalid` on the consumer's\n * model, or into `landscape.invalid` + one `spine.landscape-invalid` per service\n * when the second edge is on the map (verification 2026-09-04).\n *\n * The direction is stated rather than the current placement, and it holds\n * either way round: out of the owner's `extend` block onto the map is always\n * safe, back in is not while another service names the store. (In practice a\n * store this finding reaches is one the MAP declares — a second consumer's edge\n * cannot resolve against an `extend` block, so it could never have been drawn\n * — which is the same fact said from the other side.)\n */\nfunction reversibility(store: NestedStore, others: string[]): string {\n const named = others.length === 1 ? `${others[0]!}'s edge` : `${others.join(\", \")}'s edges`;\n return (\n `. Both placements give it the same id, ${store.fqn}, but with a second consumer the move is ONE-WAY: ` +\n `${named} resolves only against a declaration the MAP holds — a per-service project is \\`architecture/\\` ` +\n `plus one model, so an \\`extend\\` block is visible to its own service's project alone. Out of ` +\n `${store.owner}'s \\`extend\\` block onto ${store.owner}'s element on the map is always safe; moving it ` +\n `back INTO the \\`extend\\` block breaks that edge — \\`c4.invalid\\` on the other service's model, or ` +\n `\\`landscape.invalid\\` when the edge is drawn on the map`\n );\n}\n\n/**\n * Where the store goes, named for the consumer's own model SHAPE — which this\n * sentence used to assert instead of reading. It always prescribed the\n * extending-shape remedy, and following it on a standalone consumer turns a\n * clean run into `c4.invalid`: a model that declares its own `specification` is\n * parsed alone, so `extend <fqn>` there resolves nothing at all (measured — one\n * error, \"Could not resolve reference to Element named '<fqn>'\"). The two\n * placements otherwise give the store the SAME id, which is the fact that makes\n * the choice cheap: measured on examples/docs, moving `db` out of\n * order-service's extend block into `orderService` on the map left the model at\n * 12 elements · 8 relationships, left `Covers: marketplace.orderService.db` in\n * its arch.spec.md resolving, and left checkout-web's edge to it resolving too.\n */\nfunction placement(at: { fqn: string; local: string; path: string; shape: ConsumerShape }): string {\n // Direction, not symmetry. The clause used to read \"moving the declaration\n // between them later rewrites no edge\", which is true only while this store\n // has ONE consumer — the state this finding is in — and false the moment the\n // second consumer it goes on to promise arrives: that consumer's edge resolves\n // against the map's declaration alone. Saying it unconditionally here is what\n // made `datastore-shared` inherit a sentence that was false in every state it\n // printed in (verification 2026-09-04).\n const same =\n `the two placements give it the same id, ${at.fqn}.${at.local}, and with one consumer either move ` +\n `is free — out of the \\`extend\\` block onto the map always is, and back into it while no other ` +\n `service's model or map edge names the store, an \\`extend\\` block being visible to its own service's ` +\n `project alone`;\n if (at.shape === \"extending\") {\n return (\n `A store one service owns is nested UNDER that service's element: write it in the ` +\n `\\`extend ${at.fqn} { … }\\` block of ${at.path}, or inside '${at.fqn}' here when the map draws ` +\n `that service's containers, and delete this peer — ${same}.`\n );\n }\n // The two absent-extending-model arms differ in what there is to do: a\n // STANDALONE model exists and can be migrated; with no model at all there is\n // nothing to migrate, and \"migrate the model\" sent a reader looking for a file\n // loam had just said it could not find (verification 2026-09-04).\n const otherwise =\n at.shape === \"standalone\"\n ? `${at.path} declares its own \\`specification\\`, so it is parsed alone and \\`extend ${at.fqn}\\` ` +\n `resolves nothing there`\n : `loam read no extending model at ${at.path}`;\n const migrate =\n at.shape === \"standalone\"\n ? `migrate the model to the extending shape (SCHEMA.md, \"Two shapes of a service model\")`\n : `write an extending model at ${at.path} (SCHEMA.md, \"Two shapes of a service model\")`;\n return (\n `A store one service owns is nested UNDER that service's element, and this consumer has no ` +\n `extending model to nest it in (${otherwise}): write it inside '${at.fqn}' here, where the map draws ` +\n `that service's containers, and delete this peer — or ${migrate} and write it in an ` +\n `\\`extend ${at.fqn} { … }\\` block there, where ${same}.`\n );\n}\n\n/**\n * The one-consumer message, and it names the PLACEMENT rather than a directory:\n * the remedy used to read \"move it into services/<id>/model.likec4 as a nested\n * container, or add the second consumer's edge\", which offered two remedies as\n * equals and left the second one impossible — an edge naming the moved store is\n * `landscape.invalid` for loam, so an author who took the first remedy believed\n * they had shut the second door (#01). They had not, and the sentence now says\n * how: a later consumer is drawn in ITS OWN model, and loam counts that — as\n * long as that model EXTENDS the map, which the caveat below is careful to say.\n */\nfunction privateFinding(store: Elem, consumer: string, input: DatastoreInput): Finding {\n const fqn = input.drawn.find((e) => input.resolve(e.id) === consumer)?.id ?? consumer;\n const local = store.id.split(\".\").at(-1) ?? store.id;\n const model = input.attested.find((m) => m.service === consumer);\n const shape: ConsumerShape = model === undefined ? null : model.extending ? \"extending\" : \"standalone\";\n return {\n severity: \"warn\",\n code: \"landscape.datastore-private\",\n subject: store.title,\n message:\n `landscape: '${store.title}' is a datastore with a single consumer at fleet level ` +\n `('${consumer}') — drawn as a peer it reads as a system in its own right, available to be ` +\n `depended on. ${placement({ fqn, local, path: `${input.pathOf(consumer)}/model.likec4`, shape })} ` +\n `Nesting closes no door: a second service reaching the same data later draws a container-level ` +\n `edge to ${fqn}.${local} in ITS OWN model plus a service-level edge to '${fqn}' here, and loam ` +\n `counts a consumer an EXTENDING model attests exactly as it counts one drawn here — so that ` +\n `state is \\`landscape.datastore-shared\\`, never silence. A consumer whose model stands alone is ` +\n `not counted (its ids are its own file's, never the map's), so its edge belongs on the map. ` +\n `Which placement: that edge RESOLVES only against a store the map declares (a per-service ` +\n `project is architecture/ plus one model), so ` +\n (shape === \"extending\"\n ? `nest it here when a second consumer is foreseeable and in the model when it is not. `\n : `the map is the placement that keeps that door open. `) +\n `If another service reaches the same data today, draw its edge and this becomes that warning now`,\n };\n}\n\n/** The two-or-more message, shared by the peer and the nested store — one word about coupling, once. */\nfunction sharedFinding(title: string, consumers: string[], where: string, tail: string): Finding {\n return {\n severity: \"warn\",\n code: \"landscape.datastore-shared\",\n subject: title,\n message:\n `landscape: '${title}' is a datastore shared by ${consumers.length} services ` +\n `(${consumers.join(\", \")})${where} — the strongest coupling two services can have, and the ` +\n `hardest to undo. Shared means the same DATA: if they read the same tables or keys, keep it ` +\n `where it is and let this warning state the coupling; if they only share a host or cluster ` +\n `(two schemas, two lock paths), that is operational blast radius — a runbook fact — and the ` +\n `honest model is one private store per service${tail}`,\n };\n}\n"]}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `landscape.service-isolated` — a service the fleet map draws and nothing
3
+ * reaches, graded on EVIDENCE rather than on the shape alone.
4
+ *
5
+ * The state is the one `loam seed --from fleet.yaml` leaves for every service
6
+ * nobody listed under `calls:`: a bound element with no edge. Nothing used to
7
+ * report it — `landscape.service-unmodelled` wants a missing element, and the
8
+ * element is present — so a service could be adopted with a full artifact set
9
+ * and stay invisible to every cross-service check while the run ended on
10
+ * `landscape.matched`.
11
+ *
12
+ * Why the evidence gate, and not "every edgeless bound element": a warning with
13
+ * no correct fix is the warning somebody silences. A worker or a cron that
14
+ * genuinely calls nothing has an edgeless element that is TRUE, and there is
15
+ * no tag loam reads for "deliberately isolated" (inventing one is the invented
16
+ * need the non-goals forbid). So this fires only when the service's own
17
+ * `model.likec4` parses and declares at least one call across its boundary —
18
+ * a JOIN between two authored documents: the model attests a call the map
19
+ * does not draw. The list comes from `attestedCalls`, the same derivation the
20
+ * adopt brief names those calls with, so the brief and this check can never
21
+ * disagree about which edges the map owes. Silent, on purpose, for a service
22
+ * with no model (seed's fleet), an unparseable model (`c4.invalid` owns it), a
23
+ * model reaching nothing, and an unmodelled service (`service-unmodelled` owns
24
+ * it).
25
+ *
26
+ * `#external` is never a SUBJECT, and that is now enforced rather than assumed.
27
+ * The claim used to rest on "an `#external` element is not an enumerated
28
+ * service" — true of the ordinary case and false of the reachable one: an
29
+ * element tagged `#external` and bound with `metadata { service '<id>' }` to a
30
+ * directory that exists resolved like any other, so a foreign box answered for
31
+ * one of our services (R4). The two neighbouring binding checks have always
32
+ * skipped the tag; the input set here does too, so "deliberately not ours"
33
+ * means the same thing in all three.
34
+ *
35
+ * The subject set ONLY, and that boundary is the whole of the rule. The filter
36
+ * also sat inside the `touched` walk for one round, which answers a different
37
+ * question — does the map draw any edge on this service — and got it wrong: a
38
+ * kind carrying the tag (`element topic { #external }`, the idiom
39
+ * `examples/docs/architecture/landscape.likec4` defends at length) puts
40
+ * `#external` on every element of that kind, so an edge drawn from a service's
41
+ * OWN topic stopped touching it and the warning fired on a service the map
42
+ * demonstrably reaches — while `loam adopt`, whose `landscape.touched` has no
43
+ * tag filter, said `touched: true` on the same tree. Two answers to one rule
44
+ * is the drift this file's banner promises there cannot be, so the walk below
45
+ * is the brief's predicate exactly, tag and all.
46
+ *
47
+ * A sub-package of its own because `fleet/` sits at the five-file cap, and it
48
+ * imports NOTHING from `fleet/`: a child reaching back into its parent while
49
+ * the parent calls the child is the package cycle `scripts/package-graph.mjs`
50
+ * refuses, which is why the input is one record the caller fills. The models
51
+ * are no longer read here either — `./attest.ts` reads each of them once for
52
+ * this check and the datastore census together.
53
+ */
54
+ import {} from "../../../../core/c4/likec4.js";
55
+ import { edgeTemplates, spellCall } from "../../../../core/c4/resolve/attested.js";
56
+ import {} from "../../../../core/vocabulary/report.js";
57
+ import {} from "./attest.js";
58
+ /** How many attested calls the message spells out before "…" — three reads; thirty is a wall. */
59
+ const NAMED_CALLS = 3;
60
+ export function isolationFindings(input) {
61
+ const { land, resolve, pathOf } = input;
62
+ // One pass over the relationships: a service is touched when EITHER endpoint
63
+ // resolves to it. An intra-service edge (`svc.api -> svc.db`) therefore
64
+ // counts, which is the brief's `landscape.touched` predicate exactly, and
65
+ // what keeps a self-model drawn as one service of container edges silent.
66
+ // NO tag filter here, deliberately — see the banner: an `#external` endpoint
67
+ // is still an edge the map draws on whatever it resolves to, and skipping one
68
+ // made this check contradict `loam adopt` on a service whose own topic (a
69
+ // kind-tagged `#external` element) was the endpoint.
70
+ const touched = new Set();
71
+ for (const r of land.relationships) {
72
+ for (const endpoint of [r.source, r.target])
73
+ touched.add(resolve(endpoint));
74
+ }
75
+ const findings = [];
76
+ for (const model of input.attested) {
77
+ if (!input.modelled.has(model.service) || touched.has(model.service))
78
+ continue;
79
+ const calls = model.calls;
80
+ if (calls.length === 0)
81
+ continue;
82
+ // `modelled.has` proves a non-external element resolves; the find is the
83
+ // same predicate spelled on the elements, and an empty answer fails closed
84
+ // rather than naming an element that is not there.
85
+ const element = land.elements.find((e) => resolve(e.id) === model.service && !input.external.has(e.id));
86
+ if (element === undefined)
87
+ continue;
88
+ const named = calls.slice(0, NAMED_CALLS).map(spellCall).join(", ") + (calls.length > NAMED_CALLS ? ", …" : "");
89
+ // The repair form follows the calls' direction (`edgeTemplates`): the
90
+ // outbound form alone, spelled here until 2026-09-03, sent an inbound
91
+ // call backwards.
92
+ const forms = edgeTemplates(element.id, calls)
93
+ .map((t) => `\`${t}\``)
94
+ .join(" or ");
95
+ findings.push({
96
+ severity: "warn",
97
+ code: "landscape.service-isolated",
98
+ subject: model.service,
99
+ message:
100
+ // "the fleet map (architecture/)", never the landscape FILE: the input
101
+ // is the whole PROJECT, so an edge added to `architecture/extra.likec4`
102
+ // and never to landscape.likec4 silences this finding — measured on both
103
+ // the seed lab and examples/docs. Naming one file told a reader the edge
104
+ // had to land there, which SCHEMA, `loam explain` and the brief all
105
+ // already contradicted (verification 2026-09-04).
106
+ `landscape: ${pathOf(model.service)}/ resolves to '${element.id}' and no edge in the fleet map ` +
107
+ `(architecture/) touches it, while ${pathOf(model.service)}/model.likec4 declares ${calls.length} ` +
108
+ `call(s) across its boundary ` +
109
+ `(${named}) — the service is drawn and invisible to every cross-service check. Draw each as one edge on ` +
110
+ `${element.id}: ${forms} for a call, ` +
111
+ `\`publishes\`/\`consumes\` for an event, naming the other party as the map already spells it`,
112
+ });
113
+ }
114
+ return findings;
115
+ }
116
+ //# sourceMappingURL=isolation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isolation.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/map/isolation.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `landscape.service-isolated` — a service the fleet map draws and nothing\n * reaches, graded on EVIDENCE rather than on the shape alone.\n *\n * The state is the one `loam seed --from fleet.yaml` leaves for every service\n * nobody listed under `calls:`: a bound element with no edge. Nothing used to\n * report it — `landscape.service-unmodelled` wants a missing element, and the\n * element is present — so a service could be adopted with a full artifact set\n * and stay invisible to every cross-service check while the run ended on\n * `landscape.matched`.\n *\n * Why the evidence gate, and not \"every edgeless bound element\": a warning with\n * no correct fix is the warning somebody silences. A worker or a cron that\n * genuinely calls nothing has an edgeless element that is TRUE, and there is\n * no tag loam reads for \"deliberately isolated\" (inventing one is the invented\n * need the non-goals forbid). So this fires only when the service's own\n * `model.likec4` parses and declares at least one call across its boundary —\n * a JOIN between two authored documents: the model attests a call the map\n * does not draw. The list comes from `attestedCalls`, the same derivation the\n * adopt brief names those calls with, so the brief and this check can never\n * disagree about which edges the map owes. Silent, on purpose, for a service\n * with no model (seed's fleet), an unparseable model (`c4.invalid` owns it), a\n * model reaching nothing, and an unmodelled service (`service-unmodelled` owns\n * it).\n *\n * `#external` is never a SUBJECT, and that is now enforced rather than assumed.\n * The claim used to rest on \"an `#external` element is not an enumerated\n * service\" — true of the ordinary case and false of the reachable one: an\n * element tagged `#external` and bound with `metadata { service '<id>' }` to a\n * directory that exists resolved like any other, so a foreign box answered for\n * one of our services (R4). The two neighbouring binding checks have always\n * skipped the tag; the input set here does too, so \"deliberately not ours\"\n * means the same thing in all three.\n *\n * The subject set ONLY, and that boundary is the whole of the rule. The filter\n * also sat inside the `touched` walk for one round, which answers a different\n * question — does the map draw any edge on this service — and got it wrong: a\n * kind carrying the tag (`element topic { #external }`, the idiom\n * `examples/docs/architecture/landscape.likec4` defends at length) puts\n * `#external` on every element of that kind, so an edge drawn from a service's\n * OWN topic stopped touching it and the warning fired on a service the map\n * demonstrably reaches — while `loam adopt`, whose `landscape.touched` has no\n * tag filter, said `touched: true` on the same tree. Two answers to one rule\n * is the drift this file's banner promises there cannot be, so the walk below\n * is the brief's predicate exactly, tag and all.\n *\n * A sub-package of its own because `fleet/` sits at the five-file cap, and it\n * imports NOTHING from `fleet/`: a child reaching back into its parent while\n * the parent calls the child is the package cycle `scripts/package-graph.mjs`\n * refuses, which is why the input is one record the caller fills. The models\n * are no longer read here either — `./attest.ts` reads each of them once for\n * this check and the datastore census together.\n */\nimport { type LoadedDoc } from \"../../../../core/c4/likec4.js\";\nimport { edgeTemplates, spellCall } from \"../../../../core/c4/resolve/attested.js\";\nimport { type Finding } from \"../../../../core/vocabulary/report.js\";\nimport { type AttestedModel } from \"./attest.js\";\n\nexport interface IsolationInput {\n /** The parsed landscape. */\n land: LoadedDoc;\n /**\n * The services a NON-`#external` element resolves to — `service-unmodelled`'s\n * set with the tag filter applied, which is what keeps a foreign box from\n * answering for one of our directories.\n */\n modelled: ReadonlySet<string>;\n /** The ids of the map's `#external` elements: never a subject — and never what the message names. */\n external: ReadonlySet<string>;\n /** What each service's own model attests, read once by `./attest.ts` for this check and the store census. */\n attested: readonly AttestedModel[];\n /** The shared element→service resolver every edge join uses. */\n resolve: (id: string) => string;\n /** Where a service's directory sits, repo-relative — the enumeration's answer, never `services/<id>`. */\n pathOf: (id: string) => string;\n}\n\n/** How many attested calls the message spells out before \"…\" — three reads; thirty is a wall. */\nconst NAMED_CALLS = 3;\n\nexport function isolationFindings(input: IsolationInput): Finding[] {\n const { land, resolve, pathOf } = input;\n // One pass over the relationships: a service is touched when EITHER endpoint\n // resolves to it. An intra-service edge (`svc.api -> svc.db`) therefore\n // counts, which is the brief's `landscape.touched` predicate exactly, and\n // what keeps a self-model drawn as one service of container edges silent.\n // NO tag filter here, deliberately — see the banner: an `#external` endpoint\n // is still an edge the map draws on whatever it resolves to, and skipping one\n // made this check contradict `loam adopt` on a service whose own topic (a\n // kind-tagged `#external` element) was the endpoint.\n const touched = new Set<string>();\n for (const r of land.relationships) {\n for (const endpoint of [r.source, r.target]) touched.add(resolve(endpoint));\n }\n\n const findings: Finding[] = [];\n for (const model of input.attested) {\n if (!input.modelled.has(model.service) || touched.has(model.service)) continue;\n const calls = model.calls;\n if (calls.length === 0) continue;\n // `modelled.has` proves a non-external element resolves; the find is the\n // same predicate spelled on the elements, and an empty answer fails closed\n // rather than naming an element that is not there.\n const element = land.elements.find((e) => resolve(e.id) === model.service && !input.external.has(e.id));\n if (element === undefined) continue;\n const named = calls.slice(0, NAMED_CALLS).map(spellCall).join(\", \") + (calls.length > NAMED_CALLS ? \", …\" : \"\");\n // The repair form follows the calls' direction (`edgeTemplates`): the\n // outbound form alone, spelled here until 2026-09-03, sent an inbound\n // call backwards.\n const forms = edgeTemplates(element.id, calls)\n .map((t) => `\\`${t}\\``)\n .join(\" or \");\n findings.push({\n severity: \"warn\",\n code: \"landscape.service-isolated\",\n subject: model.service,\n message:\n // \"the fleet map (architecture/)\", never the landscape FILE: the input\n // is the whole PROJECT, so an edge added to `architecture/extra.likec4`\n // and never to landscape.likec4 silences this finding — measured on both\n // the seed lab and examples/docs. Naming one file told a reader the edge\n // had to land there, which SCHEMA, `loam explain` and the brief all\n // already contradicted (verification 2026-09-04).\n `landscape: ${pathOf(model.service)}/ resolves to '${element.id}' and no edge in the fleet map ` +\n `(architecture/) touches it, while ${pathOf(model.service)}/model.likec4 declares ${calls.length} ` +\n `call(s) across its boundary ` +\n `(${named}) — the service is drawn and invisible to every cross-service check. Draw each as one edge on ` +\n `${element.id}: ${forms} for a call, ` +\n `\\`publishes\\`/\\`consumes\\` for an event, naming the other party as the map already spells it`,\n });\n }\n return findings;\n}\n"]}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The fleet's use cases, graded — the CALL, with the two vocabulary reads it
3
+ * needs, lifted out of `../landscape.ts` when that module reached its line
4
+ * limit.
5
+ *
6
+ * The seam is a phase rather than a subject: everything here happens after the
7
+ * map has parsed and grades documents the map does not own — every
8
+ * `dynamic view` in `architecture/landscape.likec4` and in every
9
+ * `architecture/usecases/*.likec4`. `../usecases/usecases.ts` owns the opt-in and
10
+ * the grades; this owns which inputs they are handed, which is the part that has
11
+ * twice been got wrong and is worth stating in one place.
12
+ *
13
+ * A sub-package of `fleet/` for `map/isolation.ts`'s reason, and with its rule:
14
+ * it imports NOTHING from `fleet/` itself — a child reaching back into its
15
+ * parent while the parent calls the child is the package cycle
16
+ * `scripts/package-graph.mjs` refuses — so everything it needs arrives in one
17
+ * record the caller fills.
18
+ */
19
+ import { readCapabilityVocabulary } from "../../../../core/capabilities/capabilities.js";
20
+ import { capabilityRequirementIndex, gradableCapabilityIds } from "../../../../core/capabilities/findings.js";
21
+ import { parseRequirements, readRequirementsDocument } from "../../../../core/document/parse.js";
22
+ import { FleetContext } from "../../../../core/fleet-context.js";
23
+ import { useCaseFindings } from "../usecases/usecases.js";
24
+ /**
25
+ * The capability ladder is APPLIED BY ITS OWN FUNCTION rather than re-spelled
26
+ * here: an absent or unreadable capabilities.yaml means silence for the whole
27
+ * family, not a fleet full of unresolved tags, so `null` travels rather than an
28
+ * empty list — which the join would read as "the fleet declares no capabilities"
29
+ * and grade every tag against. `gradableCapabilityIds`
30
+ * (`core/capabilities/findings.ts`) is the one statement of that rule, and this
31
+ * is a caller of it precisely so a fourth un-gradable vocabulary state cannot be
32
+ * fixed in core while the command layer keeps handing the join a whole key set.
33
+ *
34
+ * Both reads are already paid for under `--all`: `capabilityFleetFindings` has
35
+ * asked for the vocabulary, and every requirement document behind the `#req-`
36
+ * tag was parsed by it, so the fleet memo answers from cache. The requirement
37
+ * index is built here rather than inside the use-case package so that package
38
+ * never learns what a capability document is.
39
+ */
40
+ export async function fleetUseCaseFindings(input) {
41
+ const { docsDir, fleet } = input;
42
+ const vocabulary = fleet === undefined ? await readCapabilityVocabulary(docsDir) : await fleet.capabilities(docsDir);
43
+ const capabilityReqs = await capabilityRequirementIndex(vocabulary, fleet === undefined
44
+ ? async (p) => parseRequirements(await readRequirementsDocument(p))
45
+ : (p) => fleet.readRequirements(p));
46
+ return useCaseFindings({
47
+ views: input.views,
48
+ elements: input.elements,
49
+ relationships: input.relationships,
50
+ services: input.services,
51
+ resolve: input.resolve,
52
+ capabilities: gradableCapabilityIds(vocabulary),
53
+ requirementsOf: (capability) => capabilityReqs.byCapability.get(capability),
54
+ });
55
+ }
56
+ //# sourceMappingURL=usecases.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"usecases.js","sourceRoot":"","sources":["../../../../../src/commands/validate/fleet/map/usecases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,wBAAwB,EAAE,MAAM,+CAA+C,CAAC;AACzF,OAAO,EAAE,0BAA0B,EAAE,qBAAqB,EAAE,MAAM,2CAA2C,CAAC;AAC9G,OAAO,EAAE,iBAAiB,EAAE,wBAAwB,EAAE,MAAM,oCAAoC,CAAC;AACjG,OAAO,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AAKjE,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAsB1D;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,KAAoB;IAC7D,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC;IACjC,MAAM,UAAU,GACd,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,wBAAwB,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IACpG,MAAM,cAAc,GAAG,MAAM,0BAA0B,CACrD,UAAU,EACV,KAAK,KAAK,SAAS;QACjB,CAAC,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,iBAAiB,CAAC,MAAM,wBAAwB,CAAC,CAAC,CAAC,CAAC;QACnE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,CACrC,CAAC;IACF,OAAO,eAAe,CAAC;QACrB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,YAAY,EAAE,qBAAqB,CAAC,UAAU,CAAC;QAC/C,cAAc,EAAE,CAAC,UAAU,EAAE,EAAE,CAAC,cAAc,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC;KAC5E,CAAC,CAAC;AACL,CAAC","sourcesContent":["/**\n * The fleet's use cases, graded — the CALL, with the two vocabulary reads it\n * needs, lifted out of `../landscape.ts` when that module reached its line\n * limit.\n *\n * The seam is a phase rather than a subject: everything here happens after the\n * map has parsed and grades documents the map does not own — every\n * `dynamic view` in `architecture/landscape.likec4` and in every\n * `architecture/usecases/*.likec4`. `../usecases/usecases.ts` owns the opt-in and\n * the grades; this owns which inputs they are handed, which is the part that has\n * twice been got wrong and is worth stating in one place.\n *\n * A sub-package of `fleet/` for `map/isolation.ts`'s reason, and with its rule:\n * it imports NOTHING from `fleet/` itself — a child reaching back into its\n * parent while the parent calls the child is the package cycle\n * `scripts/package-graph.mjs` refuses — so everything it needs arrives in one\n * record the caller fills.\n */\nimport { readCapabilityVocabulary } from \"../../../../core/capabilities/capabilities.js\";\nimport { capabilityRequirementIndex, gradableCapabilityIds } from \"../../../../core/capabilities/findings.js\";\nimport { parseRequirements, readRequirementsDocument } from \"../../../../core/document/parse.js\";\nimport { FleetContext } from \"../../../../core/fleet-context.js\";\nimport type { DocsDir } from \"../../../../core/kernel/ids/dirs.js\";\nimport type { Elem, Rel } from \"../../../../core/c4/likec4.js\";\nimport type { ParsedView } from \"../../../../core/c4/parsed/dynamic-views.js\";\nimport type { Finding } from \"../../../../core/vocabulary/report.js\";\nimport { useCaseFindings } from \"../usecases/usecases.js\";\n\nexport interface FleetUseCases {\n docsDir: DocsDir;\n /**\n * The views come from the PROJECT load, never from a single-file fallback,\n * and that is the file-naming rule made mechanical instead of remembered:\n * only the project load gives a view the `sourcePath` a finding has to name,\n * while a single-file load calls every document `source.c4` — so a message\n * built from that load would send its reader to `architecture/source.c4`, a\n * file that has never existed. No project, no views, and nothing to get wrong.\n */\n views: readonly ParsedView[];\n /** The map's model — what a hop is resolved against. */\n elements: Elem[];\n relationships: Rel[];\n /** The enumerated fleet, and the resolver every other edge join in the target uses. */\n services: ReadonlySet<string>;\n resolve: (id: string) => string;\n fleet?: FleetContext;\n}\n\n/**\n * The capability ladder is APPLIED BY ITS OWN FUNCTION rather than re-spelled\n * here: an absent or unreadable capabilities.yaml means silence for the whole\n * family, not a fleet full of unresolved tags, so `null` travels rather than an\n * empty list — which the join would read as \"the fleet declares no capabilities\"\n * and grade every tag against. `gradableCapabilityIds`\n * (`core/capabilities/findings.ts`) is the one statement of that rule, and this\n * is a caller of it precisely so a fourth un-gradable vocabulary state cannot be\n * fixed in core while the command layer keeps handing the join a whole key set.\n *\n * Both reads are already paid for under `--all`: `capabilityFleetFindings` has\n * asked for the vocabulary, and every requirement document behind the `#req-`\n * tag was parsed by it, so the fleet memo answers from cache. The requirement\n * index is built here rather than inside the use-case package so that package\n * never learns what a capability document is.\n */\nexport async function fleetUseCaseFindings(input: FleetUseCases): Promise<Finding[]> {\n const { docsDir, fleet } = input;\n const vocabulary =\n fleet === undefined ? await readCapabilityVocabulary(docsDir) : await fleet.capabilities(docsDir);\n const capabilityReqs = await capabilityRequirementIndex(\n vocabulary,\n fleet === undefined\n ? async (p) => parseRequirements(await readRequirementsDocument(p))\n : (p) => fleet.readRequirements(p),\n );\n return useCaseFindings({\n views: input.views,\n elements: input.elements,\n relationships: input.relationships,\n services: input.services,\n resolve: input.resolve,\n capabilities: gradableCapabilityIds(vocabulary),\n requirementsOf: (capability) => capabilityReqs.byCapability.get(capability),\n });\n}\n"]}