@smthrs/kernel 0.0.0-stage → 1.0.0-rc.3

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 (267) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -2
  4. package/dist/cjs/CapabilitySet.d.ts +6 -0
  5. package/dist/cjs/CapabilitySet.d.ts.map +1 -0
  6. package/dist/cjs/CapabilitySet.js +19 -0
  7. package/dist/cjs/CapabilitySet.js.map +7 -0
  8. package/dist/cjs/ChildProcessEnvironment.d.ts +60 -0
  9. package/dist/cjs/ChildProcessEnvironment.d.ts.map +1 -0
  10. package/dist/cjs/ChildProcessEnvironment.js +61 -0
  11. package/dist/cjs/ChildProcessEnvironment.js.map +7 -0
  12. package/dist/cjs/ChildProcessSpawner.d.ts +100 -0
  13. package/dist/cjs/ChildProcessSpawner.d.ts.map +1 -0
  14. package/dist/cjs/ChildProcessSpawner.js +291 -0
  15. package/dist/cjs/ChildProcessSpawner.js.map +7 -0
  16. package/dist/cjs/CommandLine.d.ts +173 -0
  17. package/dist/cjs/CommandLine.d.ts.map +1 -0
  18. package/dist/cjs/CommandLine.js +54 -0
  19. package/dist/cjs/CommandLine.js.map +7 -0
  20. package/dist/cjs/ContainedSpawner.d.ts +135 -0
  21. package/dist/cjs/ContainedSpawner.d.ts.map +1 -0
  22. package/dist/cjs/ContainedSpawner.js +141 -0
  23. package/dist/cjs/ContainedSpawner.js.map +7 -0
  24. package/dist/cjs/FileSystem.d.ts +452 -0
  25. package/dist/cjs/FileSystem.d.ts.map +1 -0
  26. package/dist/cjs/FileSystem.js +890 -0
  27. package/dist/cjs/FileSystem.js.map +7 -0
  28. package/dist/cjs/FileSystemBatch.d.ts +94 -0
  29. package/dist/cjs/FileSystemBatch.d.ts.map +1 -0
  30. package/dist/cjs/FileSystemBatch.js +31 -0
  31. package/dist/cjs/FileSystemBatch.js.map +7 -0
  32. package/dist/cjs/GrantEvent.d.ts +240 -0
  33. package/dist/cjs/GrantEvent.d.ts.map +1 -0
  34. package/dist/cjs/GrantEvent.js +115 -0
  35. package/dist/cjs/GrantEvent.js.map +7 -0
  36. package/dist/cjs/GrantStore.d.ts +270 -0
  37. package/dist/cjs/GrantStore.d.ts.map +1 -0
  38. package/dist/cjs/GrantStore.js +857 -0
  39. package/dist/cjs/GrantStore.js.map +7 -0
  40. package/dist/cjs/HostError.d.ts +36 -0
  41. package/dist/cjs/HostError.d.ts.map +1 -0
  42. package/dist/cjs/HostError.js +17 -0
  43. package/dist/cjs/HostError.js.map +7 -0
  44. package/dist/cjs/HostServices.d.ts +101 -0
  45. package/dist/cjs/HostServices.d.ts.map +1 -0
  46. package/dist/cjs/HostServices.js +66 -0
  47. package/dist/cjs/HostServices.js.map +7 -0
  48. package/dist/cjs/HttpClient.d.ts +177 -0
  49. package/dist/cjs/HttpClient.d.ts.map +1 -0
  50. package/dist/cjs/HttpClient.js +217 -0
  51. package/dist/cjs/HttpClient.js.map +7 -0
  52. package/dist/cjs/Jj.d.ts +67 -0
  53. package/dist/cjs/Jj.d.ts.map +1 -0
  54. package/dist/cjs/Jj.js +118 -0
  55. package/dist/cjs/Jj.js.map +7 -0
  56. package/dist/cjs/JournalGrantStore.d.ts +65 -0
  57. package/dist/cjs/JournalGrantStore.d.ts.map +1 -0
  58. package/dist/cjs/JournalGrantStore.js +354 -0
  59. package/dist/cjs/JournalGrantStore.js.map +7 -0
  60. package/dist/cjs/Path.d.ts +37 -0
  61. package/dist/cjs/Path.d.ts.map +1 -0
  62. package/dist/cjs/Path.js +46 -0
  63. package/dist/cjs/Path.js.map +7 -0
  64. package/dist/cjs/ProcessConfinement.d.ts +55 -0
  65. package/dist/cjs/ProcessConfinement.d.ts.map +1 -0
  66. package/dist/cjs/ProcessConfinement.js +94 -0
  67. package/dist/cjs/ProcessConfinement.js.map +7 -0
  68. package/dist/cjs/ProcessLedger.d.ts +260 -0
  69. package/dist/cjs/ProcessLedger.d.ts.map +1 -0
  70. package/dist/cjs/ProcessLedger.js +190 -0
  71. package/dist/cjs/ProcessLedger.js.map +7 -0
  72. package/dist/cjs/Rooted.d.ts +53 -0
  73. package/dist/cjs/Rooted.d.ts.map +1 -0
  74. package/dist/cjs/Rooted.js +101 -0
  75. package/dist/cjs/Rooted.js.map +7 -0
  76. package/dist/cjs/Unreachable.d.ts +27 -0
  77. package/dist/cjs/Unreachable.d.ts.map +1 -0
  78. package/dist/cjs/Unreachable.js +43 -0
  79. package/dist/cjs/Unreachable.js.map +7 -0
  80. package/dist/cjs/Workspace.d.ts +62 -0
  81. package/dist/cjs/Workspace.d.ts.map +1 -0
  82. package/dist/cjs/Workspace.js +35 -0
  83. package/dist/cjs/Workspace.js.map +7 -0
  84. package/dist/cjs/index.d.ts +161 -0
  85. package/dist/cjs/index.d.ts.map +1 -0
  86. package/dist/cjs/index.js +73 -0
  87. package/dist/cjs/index.js.map +7 -0
  88. package/dist/cjs/internal/Containment.d.ts +24 -0
  89. package/dist/cjs/internal/Containment.d.ts.map +1 -0
  90. package/dist/cjs/internal/Containment.js +30 -0
  91. package/dist/cjs/internal/Containment.js.map +7 -0
  92. package/dist/cjs/internal/Pipeline.d.ts +18 -0
  93. package/dist/cjs/internal/Pipeline.d.ts.map +1 -0
  94. package/dist/cjs/internal/Pipeline.js +90 -0
  95. package/dist/cjs/internal/Pipeline.js.map +7 -0
  96. package/dist/cjs/internal/makeCapability.d.ts +16 -0
  97. package/dist/cjs/internal/makeCapability.d.ts.map +1 -0
  98. package/dist/cjs/internal/makeCapability.js +44 -0
  99. package/dist/cjs/internal/makeCapability.js.map +7 -0
  100. package/dist/cjs/package.json +1 -0
  101. package/dist/cjs/test/FileSystemBatchContract.d.ts +15 -0
  102. package/dist/cjs/test/FileSystemBatchContract.d.ts.map +1 -0
  103. package/dist/cjs/test/FileSystemBatchContract.js +76 -0
  104. package/dist/cjs/test/FileSystemBatchContract.js.map +7 -0
  105. package/dist/cjs/test/HostContract.d.ts +225 -0
  106. package/dist/cjs/test/HostContract.d.ts.map +1 -0
  107. package/dist/cjs/test/HostContract.js +489 -0
  108. package/dist/cjs/test/HostContract.js.map +7 -0
  109. package/dist/cjs/test/TestGrantStore.d.ts +31 -0
  110. package/dist/cjs/test/TestGrantStore.d.ts.map +1 -0
  111. package/dist/cjs/test/TestGrantStore.js +65 -0
  112. package/dist/cjs/test/TestGrantStore.js.map +7 -0
  113. package/dist/esm/CapabilitySet.d.ts +6 -0
  114. package/dist/esm/CapabilitySet.d.ts.map +1 -0
  115. package/dist/esm/CapabilitySet.js +6 -0
  116. package/dist/esm/CapabilitySet.js.map +1 -0
  117. package/dist/esm/ChildProcessEnvironment.d.ts +60 -0
  118. package/dist/esm/ChildProcessEnvironment.d.ts.map +1 -0
  119. package/dist/esm/ChildProcessEnvironment.js +91 -0
  120. package/dist/esm/ChildProcessEnvironment.js.map +1 -0
  121. package/dist/esm/ChildProcessSpawner.d.ts +100 -0
  122. package/dist/esm/ChildProcessSpawner.d.ts.map +1 -0
  123. package/dist/esm/ChildProcessSpawner.js +342 -0
  124. package/dist/esm/ChildProcessSpawner.js.map +1 -0
  125. package/dist/esm/CommandLine.d.ts +173 -0
  126. package/dist/esm/CommandLine.d.ts.map +1 -0
  127. package/dist/esm/CommandLine.js +195 -0
  128. package/dist/esm/CommandLine.js.map +1 -0
  129. package/dist/esm/ContainedSpawner.d.ts +135 -0
  130. package/dist/esm/ContainedSpawner.d.ts.map +1 -0
  131. package/dist/esm/ContainedSpawner.js +180 -0
  132. package/dist/esm/ContainedSpawner.js.map +1 -0
  133. package/dist/esm/FileSystem.d.ts +452 -0
  134. package/dist/esm/FileSystem.d.ts.map +1 -0
  135. package/dist/esm/FileSystem.js +989 -0
  136. package/dist/esm/FileSystem.js.map +1 -0
  137. package/dist/esm/FileSystemBatch.d.ts +94 -0
  138. package/dist/esm/FileSystemBatch.d.ts.map +1 -0
  139. package/dist/esm/FileSystemBatch.js +26 -0
  140. package/dist/esm/FileSystemBatch.js.map +1 -0
  141. package/dist/esm/GrantEvent.d.ts +240 -0
  142. package/dist/esm/GrantEvent.d.ts.map +1 -0
  143. package/dist/esm/GrantEvent.js +155 -0
  144. package/dist/esm/GrantEvent.js.map +1 -0
  145. package/dist/esm/GrantStore.d.ts +270 -0
  146. package/dist/esm/GrantStore.d.ts.map +1 -0
  147. package/dist/esm/GrantStore.js +951 -0
  148. package/dist/esm/GrantStore.js.map +1 -0
  149. package/dist/esm/HostError.d.ts +36 -0
  150. package/dist/esm/HostError.d.ts.map +1 -0
  151. package/dist/esm/HostError.js +20 -0
  152. package/dist/esm/HostError.js.map +1 -0
  153. package/dist/esm/HostServices.d.ts +101 -0
  154. package/dist/esm/HostServices.d.ts.map +1 -0
  155. package/dist/esm/HostServices.js +76 -0
  156. package/dist/esm/HostServices.js.map +1 -0
  157. package/dist/esm/HttpClient.d.ts +177 -0
  158. package/dist/esm/HttpClient.d.ts.map +1 -0
  159. package/dist/esm/HttpClient.js +299 -0
  160. package/dist/esm/HttpClient.js.map +1 -0
  161. package/dist/esm/Jj.d.ts +67 -0
  162. package/dist/esm/Jj.d.ts.map +1 -0
  163. package/dist/esm/Jj.js +111 -0
  164. package/dist/esm/Jj.js.map +1 -0
  165. package/dist/esm/JournalGrantStore.d.ts +65 -0
  166. package/dist/esm/JournalGrantStore.d.ts.map +1 -0
  167. package/dist/esm/JournalGrantStore.js +350 -0
  168. package/dist/esm/JournalGrantStore.js.map +1 -0
  169. package/dist/esm/Path.d.ts +37 -0
  170. package/dist/esm/Path.d.ts.map +1 -0
  171. package/dist/esm/Path.js +34 -0
  172. package/dist/esm/Path.js.map +1 -0
  173. package/dist/esm/ProcessConfinement.d.ts +55 -0
  174. package/dist/esm/ProcessConfinement.d.ts.map +1 -0
  175. package/dist/esm/ProcessConfinement.js +106 -0
  176. package/dist/esm/ProcessConfinement.js.map +1 -0
  177. package/dist/esm/ProcessLedger.d.ts +260 -0
  178. package/dist/esm/ProcessLedger.d.ts.map +1 -0
  179. package/dist/esm/ProcessLedger.js +283 -0
  180. package/dist/esm/ProcessLedger.js.map +1 -0
  181. package/dist/esm/Rooted.d.ts +53 -0
  182. package/dist/esm/Rooted.d.ts.map +1 -0
  183. package/dist/esm/Rooted.js +104 -0
  184. package/dist/esm/Rooted.js.map +1 -0
  185. package/dist/esm/Unreachable.d.ts +27 -0
  186. package/dist/esm/Unreachable.d.ts.map +1 -0
  187. package/dist/esm/Unreachable.js +26 -0
  188. package/dist/esm/Unreachable.js.map +1 -0
  189. package/dist/esm/Workspace.d.ts +62 -0
  190. package/dist/esm/Workspace.d.ts.map +1 -0
  191. package/dist/esm/Workspace.js +51 -0
  192. package/dist/esm/Workspace.js.map +1 -0
  193. package/dist/esm/index.d.ts +161 -0
  194. package/dist/esm/index.d.ts.map +1 -0
  195. package/dist/esm/index.js +151 -0
  196. package/dist/esm/index.js.map +1 -0
  197. package/dist/esm/internal/Containment.d.ts +24 -0
  198. package/dist/esm/internal/Containment.d.ts.map +1 -0
  199. package/dist/esm/internal/Containment.js +27 -0
  200. package/dist/esm/internal/Containment.js.map +1 -0
  201. package/dist/esm/internal/Pipeline.d.ts +18 -0
  202. package/dist/esm/internal/Pipeline.d.ts.map +1 -0
  203. package/dist/esm/internal/Pipeline.js +68 -0
  204. package/dist/esm/internal/Pipeline.js.map +1 -0
  205. package/dist/esm/internal/makeCapability.d.ts +16 -0
  206. package/dist/esm/internal/makeCapability.d.ts.map +1 -0
  207. package/dist/esm/internal/makeCapability.js +22 -0
  208. package/dist/esm/internal/makeCapability.js.map +1 -0
  209. package/dist/esm/test/FileSystemBatchContract.d.ts +15 -0
  210. package/dist/esm/test/FileSystemBatchContract.d.ts.map +1 -0
  211. package/dist/esm/test/FileSystemBatchContract.js +57 -0
  212. package/dist/esm/test/FileSystemBatchContract.js.map +1 -0
  213. package/dist/esm/test/HostContract.d.ts +225 -0
  214. package/dist/esm/test/HostContract.d.ts.map +1 -0
  215. package/dist/esm/test/HostContract.js +460 -0
  216. package/dist/esm/test/HostContract.js.map +1 -0
  217. package/dist/esm/test/TestGrantStore.d.ts +31 -0
  218. package/dist/esm/test/TestGrantStore.d.ts.map +1 -0
  219. package/dist/esm/test/TestGrantStore.js +57 -0
  220. package/dist/esm/test/TestGrantStore.js.map +1 -0
  221. package/docs/README.md +162 -0
  222. package/docs/api.md +1532 -0
  223. package/docs/concepts/decoration-in-place.md +117 -0
  224. package/docs/concepts/filesystem-batches.md +58 -0
  225. package/docs/concepts/filesystem-confinement.md +168 -0
  226. package/docs/concepts/grant-decisions.md +181 -0
  227. package/docs/concepts/process-containment.md +155 -0
  228. package/docs/contract.md +14 -0
  229. package/docs/guides/adapt-a-new-host-platform.md +148 -0
  230. package/docs/guides/answer-permission-requests.md +151 -0
  231. package/docs/guides/authorize-network-and-model-calls.md +118 -0
  232. package/docs/guides/contain-spawned-processes.md +118 -0
  233. package/docs/guides/guard-a-host-bundle.md +109 -0
  234. package/docs/guides/persist-grants-across-restarts.md +135 -0
  235. package/docs/guides/write-a-capability-policy.md +159 -0
  236. package/docs/installation.md +115 -0
  237. package/docs/quickstart.md +182 -0
  238. package/docs/testing.md +108 -0
  239. package/docs/troubleshooting.md +281 -0
  240. package/package.json +344 -3
  241. package/src/CapabilitySet.ts +6 -0
  242. package/src/ChildProcessEnvironment.ts +98 -0
  243. package/src/ChildProcessSpawner.ts +414 -0
  244. package/src/CommandLine.ts +250 -0
  245. package/src/ContainedSpawner.ts +284 -0
  246. package/src/FileSystem.ts +1655 -0
  247. package/src/FileSystemBatch.ts +90 -0
  248. package/src/GrantEvent.ts +174 -0
  249. package/src/GrantStore.ts +1345 -0
  250. package/src/HostError.ts +37 -0
  251. package/src/HostServices.ts +133 -0
  252. package/src/HttpClient.ts +406 -0
  253. package/src/Jj.ts +159 -0
  254. package/src/JournalGrantStore.ts +461 -0
  255. package/src/Path.ts +47 -0
  256. package/src/ProcessConfinement.ts +156 -0
  257. package/src/ProcessLedger.ts +423 -0
  258. package/src/Rooted.ts +128 -0
  259. package/src/Unreachable.ts +30 -0
  260. package/src/Workspace.ts +65 -0
  261. package/src/index.ts +181 -0
  262. package/src/internal/Containment.ts +37 -0
  263. package/src/internal/Pipeline.ts +85 -0
  264. package/src/internal/makeCapability.ts +26 -0
  265. package/src/test/FileSystemBatchContract.ts +59 -0
  266. package/src/test/HostContract.ts +785 -0
  267. package/src/test/TestGrantStore.ts +73 -0
package/src/Jj.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Permission-aware Jujutsu version-control operations.
3
+ *
4
+ * There is no kernel `Jj` interface and no kernel `Jj` tag. `@smthrs/jj` owns
5
+ * both, and its error channel already names the failures the kernel adds, so
6
+ * this module is only the middleware: a `Layer` over the same tag that reads
7
+ * the raw service out of context and returns a guarded one in its place.
8
+ *
9
+ * Governing design:
10
+ * `docs/specs/Concepts/Permission Kernel.md`,
11
+ * `docs/specs/Concepts/Effect Taxonomy.md`, and
12
+ * `docs/specs/Concepts/Host Adapters.md`.
13
+ *
14
+ * @since 1.0.0-rc.0
15
+ */
16
+
17
+ import type { Action } from "@smthrs/capability/Capability"
18
+ import { permissionDenied } from "@smthrs/capability/Permission"
19
+ import { Jj, type OperationId, type Revision } from "@smthrs/jj"
20
+ import { Effect, FileSystem as EffectFileSystem, Layer, Path as EffectPath } from "effect"
21
+ import { canonicalResource } from "./FileSystem.ts"
22
+ import { GrantStore } from "./GrantStore.ts"
23
+ import { makeCapability } from "./internal/makeCapability.ts"
24
+ import { Workspace } from "./Workspace.ts"
25
+
26
+ /**
27
+ * `@smthrs/jj`'s own Jujutsu tag, unchanged. Re-exported so the kernel
28
+ * namespace stays one-stop; it is the *same* tag, never a second one.
29
+ *
30
+ * @category services
31
+ * @since 1.0.0-rc.0
32
+ */
33
+ export { Jj } from "@smthrs/jj"
34
+
35
+ /**
36
+ * Provides an unavailable Jujutsu stub.
37
+ *
38
+ * @category layers
39
+ * @since 1.0.0-rc.0
40
+ */
41
+ export { layerNoop } from "@smthrs/jj"
42
+
43
+ /**
44
+ * Constructs a Jujutsu service from an implementation.
45
+ *
46
+ * @category constructors
47
+ * @since 1.0.0-rc.0
48
+ */
49
+ export { make } from "@smthrs/jj"
50
+
51
+ /**
52
+ * Constructs an unavailable Jujutsu stub.
53
+ *
54
+ * @category constructors
55
+ * @since 1.0.0-rc.0
56
+ */
57
+ export { makeNoop } from "@smthrs/jj"
58
+
59
+ /**
60
+ * Decorates the Jujutsu service in place with operation-specific capability
61
+ * checks.
62
+ *
63
+ * The layer provides the tag it also requires: compose it over a host jj layer
64
+ * with `Layer.provide` and every consumer of `Jj` — including one that never
65
+ * heard of the kernel — resolves the guarded implementation.
66
+ *
67
+ * `workspaceAdd` canonicalizes its destination and `root` canonicalizes its
68
+ * starting directory through the *raw* filesystem before asking for
69
+ * `jj:workspace-add` and `fs:write`, or for `jj:root`, so an existing symlink
70
+ * cannot turn an inside-workspace grant into outside authority or separate the
71
+ * authorized path from the directory jj is run in.
72
+ *
73
+ * @category layers
74
+ * @since 1.0.0-rc.0
75
+ */
76
+ export const layer: Layer.Layer<
77
+ Jj,
78
+ never,
79
+ Jj | EffectFileSystem.FileSystem | EffectPath.Path | Workspace | GrantStore
80
+ > = Layer.effect(
81
+ Jj,
82
+ Effect.gen(function*() {
83
+ const jj = yield* Jj
84
+ const jjRoot = jj.root
85
+ const jjRevert = jj.revert
86
+ const jjOpRestore = jj.opRestore
87
+ const fileSystem = yield* EffectFileSystem.FileSystem
88
+ const path = yield* EffectPath.Path
89
+ const workspace = yield* Workspace
90
+ const grants = yield* GrantStore
91
+ const check = (action: Action, resource: string) =>
92
+ makeCapability(action, resource).pipe(Effect.flatMap((capability) => grants.check(capability)))
93
+ return Jj.of({
94
+ status: Effect.fn("Jj.status")(() => check("jj:status", ".").pipe(Effect.andThen(jj.status()))),
95
+ diff: Effect.fn("Jj.diff")((from, to) =>
96
+ check("jj:diff", `${from}:${to}`).pipe(Effect.andThen(jj.diff(from, to)))
97
+ ),
98
+ snapshot: Effect.fn("Jj.snapshot")((message) =>
99
+ check("jj:snapshot", message ?? "").pipe(Effect.andThen(jj.snapshot(message)))
100
+ ),
101
+ restore: Effect.fn("Jj.restore")((revision) =>
102
+ check("jj:restore", revision).pipe(Effect.andThen(jj.restore(revision)))
103
+ ),
104
+ workspaceAdd: Effect.fn("Jj.workspaceAdd")((name, destination, revision) =>
105
+ canonicalResource(fileSystem, path, workspace.root, destination).pipe(
106
+ Effect.flatMap((resource) =>
107
+ check("jj:workspace-add", resource).pipe(
108
+ Effect.andThen(check("fs:write", resource)),
109
+ Effect.andThen(canonicalResource(fileSystem, path, workspace.root, destination)),
110
+ Effect.flatMap((settled) =>
111
+ settled === resource
112
+ ? jj.workspaceAdd(name, resource, revision)
113
+ : makeCapability("jj:workspace-add", resource).pipe(
114
+ Effect.flatMap((capability) =>
115
+ Effect.fail(permissionDenied(
116
+ capability,
117
+ "workspace destination no longer names the resource that was authorized"
118
+ ))
119
+ )
120
+ )
121
+ )
122
+ )
123
+ )
124
+ )
125
+ ),
126
+ workspaceForget: Effect.fn("Jj.workspaceForget")((name) =>
127
+ check("jj:workspace-forget", name).pipe(Effect.andThen(jj.workspaceForget(name)))
128
+ ),
129
+ // `root`, `revert`, and `opRestore` are optional on the service, so the decorator
130
+ // forwards the ABSENCE too. A backend that cannot revert must keep
131
+ // reading as a backend that cannot revert: replacing it with a guarded
132
+ // method that fails on call would turn "this host has no revert" into
133
+ // "your revert was refused", which is a different answer to a caller
134
+ // deciding what it can offer.
135
+ ...jjRoot === undefined ? {} : {
136
+ root: Effect.fn("Jj.root")((from: string) =>
137
+ // Canonicalized BEFORE the check, and the canonical path is what jj
138
+ // is then run in. Checking the caller's spelling and inspecting a
139
+ // different directory is the whole symlink-alias problem: an
140
+ // authorized name would decide the answer for a repository the grant
141
+ // never mentioned.
142
+ canonicalResource(fileSystem, path, workspace.root, from).pipe(
143
+ Effect.flatMap((resource) => check("jj:root", resource).pipe(Effect.andThen(jjRoot(resource))))
144
+ )
145
+ )
146
+ },
147
+ ...jjRevert === undefined ? {} : {
148
+ revert: Effect.fn("Jj.revert")((revision: Revision) =>
149
+ check("jj:revert", revision).pipe(Effect.andThen(jjRevert(revision)))
150
+ )
151
+ },
152
+ ...jjOpRestore === undefined ? {} : {
153
+ opRestore: Effect.fn("Jj.opRestore")((operationId: OperationId) =>
154
+ check("jj:op-restore", operationId).pipe(Effect.andThen(jjOpRestore(operationId)))
155
+ )
156
+ }
157
+ })
158
+ })
159
+ )
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Journal-backed persistence and replay for capability decisions.
3
+ *
4
+ * Governing design:
5
+ * `docs/specs/Concepts/Permission Kernel.md` and
6
+ * `docs/specs/Concepts/Journal Queue.md`.
7
+ *
8
+ * @since 1.0.0-rc.0
9
+ */
10
+
11
+ import * as Capability from "@smthrs/capability/Capability"
12
+ import { GrantStoreError, Rule } from "@smthrs/capability/Permission"
13
+ import * as JournalModule from "@smthrs/journal/Journal"
14
+ import * as JournalEvent from "@smthrs/journal/JournalEvent"
15
+ import * as Effect from "effect/Effect"
16
+ import * as Layer from "effect/Layer"
17
+ import * as Schema from "effect/Schema"
18
+ import * as Semaphore from "effect/Semaphore"
19
+ import { decode, type GrantEvent, GrantEventSchema } from "./GrantEvent.ts"
20
+ import * as GrantStore from "./GrantStore.ts"
21
+ import { Workspace } from "./Workspace.ts"
22
+
23
+ /**
24
+ * Configuration for a journal-backed grant store.
25
+ *
26
+ * `policyRunId` is a deliberately dedicated run containing remembered-policy
27
+ * events. The journal has no global grant projection, so callers that want
28
+ * remembered grants to span operational runs must keep this id stable.
29
+ * `planDigest` binds run grants and envelopes to the exact active plan.
30
+ * `sourceId` is also checked during replay; events from other producers cannot
31
+ * activate kernel authority.
32
+ *
33
+ * The journal is authoritative permission storage: SqlJournal must use the
34
+ * `reject` overflow policy. Drop-capable overflow policies are unsupported,
35
+ * because a dropped grant decision cannot safely be treated as persisted.
36
+ *
37
+ * @category models
38
+ * @since 1.0.0-rc.0
39
+ */
40
+ export interface JournalGrantStoreOptions {
41
+ readonly runId: string
42
+ readonly policyRunId: string
43
+ readonly sourceId: string
44
+ readonly planDigest: string
45
+ readonly attended?: boolean
46
+ readonly rules?: ReadonlyArray<ReadonlyArray<Rule>>
47
+ readonly envelope?: {
48
+ readonly patterns: ReadonlyArray<Capability.CapabilityPattern>
49
+ readonly scope?: "run" | "remembered" | undefined
50
+ }
51
+ }
52
+
53
+ const journalFailed = (message: string, cause: unknown): GrantStoreError =>
54
+ new GrantStoreError({
55
+ code: "journal_failed",
56
+ message,
57
+ cause
58
+ })
59
+
60
+ const knownEventTypes: ReadonlySet<string> = new Set([
61
+ "flows.kernel.grant.once.v1",
62
+ "flows.kernel.grant.run.v1",
63
+ "flows.kernel.grant.run.v2",
64
+ "flows.kernel.grant.remembered.v1",
65
+ "flows.kernel.grant.denied.v1",
66
+ "flows.kernel.grant.envelope.v1"
67
+ ])
68
+
69
+ const invalidReplay = (message: string): GrantStoreError => new GrantStoreError({ code: "invalid_resolution", message })
70
+
71
+ const appendReplayedRule = (
72
+ rules: Array<NonNullable<GrantStore.MakeOptions["runRules"]>[number]>,
73
+ seen: Set<string>,
74
+ pattern: Capability.CapabilityPattern
75
+ ): void => {
76
+ const rule = new Rule({ effect: "allow", pattern })
77
+ const key = Capability.format(rule.pattern)
78
+ if (seen.has(key)) return
79
+ seen.add(key)
80
+ rules.push(rule)
81
+ }
82
+
83
+ const validId = (value: unknown): value is string => {
84
+ if (typeof value !== "string" || value.length === 0 || value.length > GrantStore.maximumIdentityLength) return false
85
+ for (let index = 0; index < value.length; index += 1) {
86
+ const unit = value.charCodeAt(index)
87
+ if (unit === 0) return false
88
+ if (unit >= 0xd800 && unit <= 0xdbff) {
89
+ const next = value.charCodeAt(index + 1)
90
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false
91
+ index += 1
92
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) {
93
+ return false
94
+ }
95
+ }
96
+ return true
97
+ }
98
+
99
+ const encodeGrantEvent = Schema.encodeSync(GrantEventSchema)
100
+
101
+ // Replay reads two runs (`policyRunId` and `runId`) and journal sequences are
102
+ // per run, so every refusal names the run it was replaying: the operator then
103
+ // knows which journal run to inspect. Replay always reads a run from its start
104
+ // and never consults a checkpoint, so compacting a run it replays makes every
105
+ // later construction fail `compacted`.
106
+ const decodeTrustedEntry = (
107
+ entry: JournalEvent.Entry,
108
+ sourceId: string,
109
+ runId: string
110
+ ): Effect.Effect<GrantEvent | undefined, GrantStoreError> => {
111
+ if (entry.sourceId !== sourceId || !knownEventTypes.has(entry.eventType)) {
112
+ return Effect.succeed(undefined)
113
+ }
114
+ if (entry.eventType === "flows.kernel.grant.run.v1") {
115
+ return Effect.fail(
116
+ invalidReplay(`legacy run grant has no captured ceiling in run ${runId} at journal sequence ${entry.seq}`)
117
+ )
118
+ }
119
+ const event = decode(entry.payload)
120
+ if (event._tag === "Failure") {
121
+ return Effect.fail(invalidReplay(`invalid grant payload in run ${runId} at journal sequence ${entry.seq}`))
122
+ }
123
+ if (event.success.eventType !== entry.eventType) {
124
+ return Effect.fail(
125
+ invalidReplay(`grant envelope/payload type mismatch in run ${runId} at journal sequence ${entry.seq}`)
126
+ )
127
+ }
128
+ return Effect.succeed(event.success)
129
+ }
130
+
131
+ // One construction-envelope critical section per Journal service instance.
132
+ // Two concurrent constructors sharing one service object can both replay the
133
+ // envelope's absence before either persists it; the lock serializes the
134
+ // re-check-then-append so exactly one construction envelope lands.
135
+ //
136
+ // The guarantee stops at the service object. The lock is a WeakMap keyed by
137
+ // the in-memory `Journal.Service`, and the persisted Input carries no
138
+ // `sourceSeq` or `dedupe` key, so two service objects over one backing journal
139
+ // (two processes, or two connections in one process) each hold their own
140
+ // permit and can both append the envelope. Replay tolerates the duplicate:
141
+ // the signature set collapses it and the rules are identical.
142
+ const constructionLocks = new WeakMap<JournalModule.Service, Semaphore.Semaphore>()
143
+
144
+ const constructionLock = (journal: JournalModule.Service): Semaphore.Semaphore => {
145
+ const existing = constructionLocks.get(journal)
146
+ if (existing !== undefined) {
147
+ return existing
148
+ }
149
+ const created = Semaphore.makeUnsafe(1)
150
+ constructionLocks.set(journal, created)
151
+ return created
152
+ }
153
+
154
+ const replayRememberedRules = (
155
+ policyRunId: string,
156
+ sourceId: string,
157
+ workspaceRoot: string
158
+ ) =>
159
+ Effect.gen(function*() {
160
+ const journal = yield* JournalModule.Journal
161
+ const rules: Array<Rule> = []
162
+ const seenRules = new Set<string>()
163
+ const envelopeSignatures = new Set<string>()
164
+ let after: JournalEvent.Seq | undefined
165
+
166
+ do {
167
+ const page = yield* journal.entries({
168
+ runId: policyRunId as JournalEvent.RunId,
169
+ ...(after === undefined ? {} : { after }),
170
+ limit: 256
171
+ })
172
+ const last = page.entries.at(-1)
173
+ if (last !== undefined && after !== undefined && last.seq <= after) {
174
+ // A page that does not advance past the cursor it was asked for is
175
+ // corrupt journal output. Following it would replay the same events
176
+ // forever; accepting it would double-apply them. Refuse construction.
177
+ return yield* Effect.fail(
178
+ invalidReplay(`non-advancing journal page in run ${policyRunId} at sequence ${last.seq}`)
179
+ )
180
+ }
181
+ for (const entry of page.entries) {
182
+ const event = yield* decodeTrustedEntry(entry, sourceId, policyRunId)
183
+ if (event === undefined) {
184
+ continue
185
+ }
186
+ if (event.eventType === "flows.kernel.grant.remembered.v1") {
187
+ if (!GrantStore.isValidGrantPattern(event.pattern, event.capability, event.tier, workspaceRoot)) {
188
+ return yield* Effect.fail(
189
+ invalidReplay(`unsafe remembered grant event in run ${policyRunId} at sequence ${entry.seq}`)
190
+ )
191
+ }
192
+ appendReplayedRule(rules, seenRules, event.pattern)
193
+ continue
194
+ }
195
+ if (event.eventType === "flows.kernel.grant.envelope.v1" && event.scope === "remembered") {
196
+ if (event.patterns.some((pattern) => !GrantStore.isValidEnvelopePattern(pattern, workspaceRoot))) {
197
+ return yield* Effect.fail(
198
+ invalidReplay(`unsafe remembered envelope event in run ${policyRunId} at sequence ${entry.seq}`)
199
+ )
200
+ }
201
+ for (const pattern of event.patterns) appendReplayedRule(rules, seenRules, pattern)
202
+ envelopeSignatures.add(GrantStore.envelopeSignature(event.planDigest, event.scope, event.patterns))
203
+ continue
204
+ }
205
+ return yield* Effect.fail(
206
+ invalidReplay(`run-scoped event found in policy journal run ${policyRunId} at sequence ${entry.seq}`)
207
+ )
208
+ }
209
+ after = last?.seq
210
+ if (!page.hasMore) {
211
+ return { rules, envelopeSignatures }
212
+ }
213
+ } while (after !== undefined)
214
+
215
+ return { rules, envelopeSignatures }
216
+ })
217
+
218
+ const replayRunRules = (
219
+ runId: string,
220
+ sourceId: string,
221
+ planDigest: string,
222
+ workspaceRoot: string
223
+ ) =>
224
+ Effect.gen(function*() {
225
+ const journal = yield* JournalModule.Journal
226
+ const rules: Array<NonNullable<GrantStore.MakeOptions["runRules"]>[number]> = []
227
+ const seenRules = new Set<string>()
228
+ const envelopeSignatures = new Set<string>()
229
+ let after: JournalEvent.Seq | undefined
230
+
231
+ do {
232
+ const page = yield* journal.entries({
233
+ runId: runId as JournalEvent.RunId,
234
+ ...(after === undefined ? {} : { after }),
235
+ limit: 256
236
+ })
237
+ const last = page.entries.at(-1)
238
+ if (last !== undefined && after !== undefined && last.seq <= after) {
239
+ // See the identical guard in `replayRememberedRules`.
240
+ return yield* Effect.fail(invalidReplay(`non-advancing journal page in run ${runId} at sequence ${last.seq}`))
241
+ }
242
+ for (const entry of page.entries) {
243
+ const event = yield* decodeTrustedEntry(entry, sourceId, runId)
244
+ if (event === undefined) {
245
+ continue
246
+ }
247
+ if (event.runId !== runId) {
248
+ return yield* Effect.fail(
249
+ invalidReplay(`grant payload run mismatch in run ${runId} at journal sequence ${entry.seq}`)
250
+ )
251
+ }
252
+ if (event.eventType === "flows.kernel.grant.once.v1" || event.eventType === "flows.kernel.grant.denied.v1") {
253
+ continue
254
+ }
255
+ if (event.eventType === "flows.kernel.grant.run.v2") {
256
+ if (event.planDigest !== planDigest) {
257
+ continue
258
+ }
259
+ if (!GrantStore.isValidGrantPattern(event.pattern, event.capability, event.tier, workspaceRoot)) {
260
+ return yield* Effect.fail(invalidReplay(`unsafe run grant event in run ${runId} at sequence ${entry.seq}`))
261
+ }
262
+ const key = `run:${JSON.stringify([event.pattern, event.ceiling])}`
263
+ if (!seenRules.has(key)) {
264
+ seenRules.add(key)
265
+ rules.push({ rule: new Rule({ effect: "allow", pattern: event.pattern }), ceiling: event.ceiling })
266
+ }
267
+ continue
268
+ }
269
+ if (event.eventType === "flows.kernel.grant.envelope.v1" && event.scope === "run") {
270
+ if (event.planDigest !== planDigest) {
271
+ continue
272
+ }
273
+ if (event.patterns.some((pattern) => !GrantStore.isValidEnvelopePattern(pattern, workspaceRoot))) {
274
+ return yield* Effect.fail(
275
+ invalidReplay(`unsafe run envelope event in run ${runId} at sequence ${entry.seq}`)
276
+ )
277
+ }
278
+ for (const pattern of event.patterns) appendReplayedRule(rules, seenRules, pattern)
279
+ envelopeSignatures.add(GrantStore.envelopeSignature(event.planDigest, event.scope, event.patterns))
280
+ continue
281
+ }
282
+ return yield* Effect.fail(
283
+ invalidReplay(`remembered event found in run journal ${runId} at sequence ${entry.seq}`)
284
+ )
285
+ }
286
+ after = last?.seq
287
+ if (!page.hasMore) {
288
+ return { rules, envelopeSignatures }
289
+ }
290
+ } while (after !== undefined)
291
+
292
+ return { rules, envelopeSignatures }
293
+ })
294
+
295
+ /**
296
+ * Makes a grant store that replays remembered policy from, and writes decisions
297
+ * to, the supplied journal.
298
+ *
299
+ * `emitDurable` commits before `GrantStore` activates a remembered or
300
+ * run-scoped rule (or resolves a denial). Any journal failure is mapped to
301
+ * `journal_failed`, so permission decisions fail closed.
302
+ *
303
+ * @category constructors
304
+ * @since 1.0.0-rc.0
305
+ */
306
+ export const make = (options: JournalGrantStoreOptions) =>
307
+ Effect.gen(function*() {
308
+ const runId = options.runId
309
+ const policyRunId = options.policyRunId
310
+ const sourceId = options.sourceId
311
+ const planDigest = options.planDigest
312
+ if (!validId(runId)) return yield* Effect.fail(invalidReplay("runId must be non-empty, bounded, well-formed text"))
313
+ if (!validId(policyRunId)) {
314
+ return yield* Effect.fail(invalidReplay("policyRunId must be non-empty, bounded, well-formed text"))
315
+ }
316
+ if (!validId(sourceId)) {
317
+ return yield* Effect.fail(invalidReplay("sourceId must be non-empty, bounded, well-formed text"))
318
+ }
319
+ if (!validId(planDigest)) {
320
+ return yield* Effect.fail(invalidReplay("planDigest must be non-empty, bounded, well-formed text"))
321
+ }
322
+ if (runId === policyRunId) {
323
+ return yield* Effect.fail(invalidReplay("runId and policyRunId must be distinct"))
324
+ }
325
+ const journal = yield* JournalModule.Journal
326
+ const workspace = yield* Workspace
327
+ const replayPolicy = replayRememberedRules(
328
+ policyRunId,
329
+ sourceId,
330
+ workspace.root
331
+ ).pipe(
332
+ Effect.mapError((cause) =>
333
+ cause instanceof GrantStoreError ? cause : journalFailed("could not replay remembered grants", cause)
334
+ )
335
+ )
336
+ const replayRun = replayRunRules(
337
+ runId,
338
+ sourceId,
339
+ planDigest,
340
+ workspace.root
341
+ ).pipe(
342
+ Effect.mapError((cause) =>
343
+ cause instanceof GrantStoreError ? cause : journalFailed("could not replay run grants", cause)
344
+ )
345
+ )
346
+ let replayedPolicy = yield* replayPolicy
347
+ let replayedRun = yield* replayRun
348
+ const persist = (event: GrantEvent): Effect.Effect<void, GrantStoreError> => {
349
+ const payload = encodeGrantEvent(event)
350
+ return Effect.gen(function*() {
351
+ // Unfenced: the grant store is the kernel's own ledger, not a run's
352
+ // lifecycle — it owns no run, and grant admissions are
353
+ // first-writer-wins records replayed by every later process.
354
+ yield* journal.emitDurableUnfenced(
355
+ new JournalEvent.Input({
356
+ runId: (
357
+ event.eventType === "flows.kernel.grant.remembered.v1"
358
+ || (event.eventType === "flows.kernel.grant.envelope.v1" && event.scope === "remembered")
359
+ ? policyRunId
360
+ : runId
361
+ ) as JournalEvent.RunId,
362
+ sourceId: sourceId as JournalEvent.SourceId,
363
+ eventType: event.eventType,
364
+ payload
365
+ })
366
+ )
367
+ }).pipe(
368
+ Effect.mapError((cause) => journalFailed("could not persist grant event", cause)),
369
+ Effect.asVoid
370
+ )
371
+ }
372
+ const envelope = options.envelope
373
+ const build = () => {
374
+ const configuredCount = options.rules?.reduce((count, rules) => count + rules.length, 0) ?? 0
375
+ const rememberedCount = replayedPolicy.rules.length
376
+ const runCount = replayedRun.rules.length
377
+ const total = configuredCount + rememberedCount + runCount
378
+ if (total > GrantStore.maximumRules) {
379
+ return Effect.fail(
380
+ invalidReplay(
381
+ `policy run ${policyRunId} replayed ${rememberedCount} remembered rules; configured rules (${configuredCount}) and replayed run rules (${runCount}) bring the total to ${total}, which exceeds the ${GrantStore.maximumRules}-rule ceiling; remove configured rules or start a new policyRunId and grant again`
382
+ )
383
+ )
384
+ }
385
+ // Envelope signatures accumulate in the policy journal exactly the way
386
+ // remembered rules do, and `GrantStore.make` refuses more than
387
+ // `maximumRules` of them with a message that names neither the journal
388
+ // nor the counts. Diagnose it here instead, so an operator reading the
389
+ // failure knows which policy run filled rather than which field overflowed.
390
+ const policySignatureCount = replayedPolicy.envelopeSignatures.size
391
+ const runSignatureCount = replayedRun.envelopeSignatures.size
392
+ const signatureTotal = policySignatureCount + runSignatureCount
393
+ if (signatureTotal > GrantStore.maximumRules) {
394
+ return Effect.fail(
395
+ invalidReplay(
396
+ `policy run ${policyRunId} replayed ${policySignatureCount} remembered envelope signatures; replayed run envelope signatures (${runSignatureCount}) bring the total to ${signatureTotal}, which exceeds the ${GrantStore.maximumRules}-envelope-signature ceiling; start a new policyRunId and grant again`
397
+ )
398
+ )
399
+ }
400
+ // GrantStore's shared envelope admission adds only patterns not already
401
+ // replayed for a seeded envelope, and checks the combined rule and
402
+ // signature counts before any construction event is persisted.
403
+ return GrantStore.make({
404
+ runId,
405
+ planDigest,
406
+ ...(options.attended === undefined ? {} : { attended: options.attended }),
407
+ rules: options.rules === undefined || options.rules.length === 0
408
+ ? [[], replayedPolicy.rules]
409
+ : [...options.rules, replayedPolicy.rules],
410
+ runRules: replayedRun.rules,
411
+ envelopeSignatures: [
412
+ ...replayedPolicy.envelopeSignatures,
413
+ ...replayedRun.envelopeSignatures
414
+ ],
415
+ ...(envelope === undefined ? {} : {
416
+ envelope: {
417
+ planDigest,
418
+ patterns: envelope.patterns,
419
+ ...(envelope.scope === undefined ? {} : { scope: envelope.scope })
420
+ }
421
+ }),
422
+ persist
423
+ })
424
+ }
425
+ if (envelope === undefined || envelope.patterns.length === 0) {
426
+ return yield* build()
427
+ }
428
+ const scope = envelope.scope ?? "run"
429
+ const signature = GrantStore.envelopeSignature(planDigest, scope, envelope.patterns)
430
+ const replayedSignatures = scope === "remembered"
431
+ ? replayedPolicy.envelopeSignatures
432
+ : replayedRun.envelopeSignatures
433
+ if (replayedSignatures.has(signature)) {
434
+ // Already durable: the seeded signature makes the construction envelope
435
+ // activate without persisting again.
436
+ return yield* build()
437
+ }
438
+ // The envelope was absent when this constructor replayed, but a concurrent
439
+ // constructor sharing this Journal service may persist it before we do.
440
+ // Re-replay the target run inside the per-service critical section, so
441
+ // exactly one such constructor appends the envelope and every other one
442
+ // replays it instead.
443
+ return yield* constructionLock(journal).withPermit(
444
+ Effect.gen(function*() {
445
+ if (scope === "remembered") {
446
+ replayedPolicy = yield* replayPolicy
447
+ } else {
448
+ replayedRun = yield* replayRun
449
+ }
450
+ return yield* build()
451
+ })
452
+ )
453
+ })
454
+
455
+ /**
456
+ * Provides a journal-backed `GrantStore`.
457
+ *
458
+ * @category layers
459
+ * @since 1.0.0-rc.0
460
+ */
461
+ export const layer = (options: JournalGrantStoreOptions) => Layer.effect(GrantStore.GrantStore)(make(options))
package/src/Path.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Explicit kernel decision for the Effect path service.
3
+ *
4
+ * Governing design:
5
+ * `docs/specs/Concepts/Permission Kernel.md` and
6
+ * `docs/specs/Concepts/Host Adapters.md`.
7
+ *
8
+ * @since 1.0.0-rc.0
9
+ */
10
+
11
+ import { Effect, Layer, Path as EffectPath } from "effect"
12
+ import * as Rooted from "./Rooted.ts"
13
+ import { Workspace } from "./Workspace.ts"
14
+
15
+ /**
16
+ * Effect's lexical path-service shape.
17
+ *
18
+ * @category models
19
+ * @since 1.0.0-rc.0
20
+ */
21
+ export type Path = EffectPath.Path
22
+
23
+ /**
24
+ * The unchanged underlying path-service tag.
25
+ *
26
+ * @category services
27
+ * @since 1.0.0-rc.0
28
+ */
29
+ export const Path = EffectPath.Path
30
+
31
+ /**
32
+ * Re-provides Effect's path service with relative paths resolved against
33
+ * `Workspace.root`, the same root the kernel spawner runs commands in. Path
34
+ * manipulation is pure and lexical, so it requires no capability check. This
35
+ * explicit layer proves that every member of the Host services closed list has
36
+ * a kernel decision.
37
+ *
38
+ * @category layers
39
+ * @since 1.0.0-rc.0
40
+ */
41
+ export const layer: Layer.Layer<Path, never, Path | Workspace> = Layer.effect(
42
+ Path,
43
+ Effect.gen(function*() {
44
+ const path = yield* Path
45
+ return Rooted.path(path, path.resolve((yield* Workspace).root))
46
+ })
47
+ )