@plurnk/plurnk-service 1.0.4 → 1.0.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 (215) hide show
  1. package/.env.defaults +55 -31
  2. package/INSTALL.md +3 -3
  3. package/README.md +4 -4
  4. package/SPEC.md +205 -193
  5. package/dist/Paths.js +1 -1
  6. package/dist/Paths.js.map +1 -1
  7. package/dist/core/ChannelWrite.d.ts +18 -18
  8. package/dist/core/ChannelWrite.d.ts.map +1 -1
  9. package/dist/core/ChannelWrite.js +17 -17
  10. package/dist/core/ChannelWrite.js.map +1 -1
  11. package/dist/core/ChannelWrite.sql +11 -11
  12. package/dist/core/Dispatcher.d.ts +11 -16
  13. package/dist/core/Dispatcher.d.ts.map +1 -1
  14. package/dist/core/Dispatcher.js +122 -125
  15. package/dist/core/Dispatcher.js.map +1 -1
  16. package/dist/core/Engine.d.ts +20 -23
  17. package/dist/core/Engine.d.ts.map +1 -1
  18. package/dist/core/Engine.js +213 -210
  19. package/dist/core/Engine.js.map +1 -1
  20. package/dist/core/Engine.sql +108 -130
  21. package/dist/core/PacketBuilder.d.ts +10 -11
  22. package/dist/core/PacketBuilder.d.ts.map +1 -1
  23. package/dist/core/PacketBuilder.js +204 -128
  24. package/dist/core/PacketBuilder.js.map +1 -1
  25. package/dist/core/ProposalLifecycle.d.ts +8 -8
  26. package/dist/core/ProposalLifecycle.d.ts.map +1 -1
  27. package/dist/core/ProposalLifecycle.js +10 -10
  28. package/dist/core/ProposalLifecycle.js.map +1 -1
  29. package/dist/core/ProviderInstantiate.d.ts +1 -0
  30. package/dist/core/ProviderInstantiate.d.ts.map +1 -1
  31. package/dist/core/ProviderInstantiate.js +9 -3
  32. package/dist/core/ProviderInstantiate.js.map +1 -1
  33. package/dist/core/SchemeRegistry.d.ts +4 -1
  34. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  35. package/dist/core/SchemeRegistry.js +18 -9
  36. package/dist/core/SchemeRegistry.js.map +1 -1
  37. package/dist/core/TelemetryChannel.d.ts +5 -5
  38. package/dist/core/TelemetryChannel.d.ts.map +1 -1
  39. package/dist/core/TelemetryChannel.js +9 -9
  40. package/dist/core/TelemetryChannel.js.map +1 -1
  41. package/dist/core/caps/CapsResolve.js +2 -2
  42. package/dist/core/caps/CapsResolve.js.map +1 -1
  43. package/dist/core/caps/DbNotifyCaps.js +2 -2
  44. package/dist/core/caps/DbNotifyCaps.js.map +1 -1
  45. package/dist/core/caps/DbSubscriptionCaps.js +7 -7
  46. package/dist/core/caps/DbSubscriptionCaps.js.map +1 -1
  47. package/dist/core/caps/SchemeCtxImpl.d.ts +2 -2
  48. package/dist/core/caps/SchemeCtxImpl.d.ts.map +1 -1
  49. package/dist/core/caps/SchemeCtxImpl.js +4 -4
  50. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  51. package/dist/core/content-hash.d.ts +2 -0
  52. package/dist/core/content-hash.d.ts.map +1 -0
  53. package/dist/core/content-hash.js +6 -0
  54. package/dist/core/content-hash.js.map +1 -0
  55. package/dist/core/fork.d.ts +1 -1
  56. package/dist/core/fork.d.ts.map +1 -1
  57. package/dist/core/fork.js +31 -26
  58. package/dist/core/fork.js.map +1 -1
  59. package/dist/core/fork.sql +41 -32
  60. package/dist/core/git-env.d.ts.map +1 -1
  61. package/dist/core/git-env.js +23 -7
  62. package/dist/core/git-env.js.map +1 -1
  63. package/dist/core/git-iso.d.ts +9 -0
  64. package/dist/core/git-iso.d.ts.map +1 -0
  65. package/dist/core/git-iso.js +227 -0
  66. package/dist/core/git-iso.js.map +1 -0
  67. package/dist/core/git-membership.d.ts +2 -2
  68. package/dist/core/git-membership.d.ts.map +1 -1
  69. package/dist/core/git-membership.js +70 -50
  70. package/dist/core/git-membership.js.map +1 -1
  71. package/dist/core/git-state.d.ts +1 -1
  72. package/dist/core/git-state.d.ts.map +1 -1
  73. package/dist/core/git-state.js +21 -11
  74. package/dist/core/git-state.js.map +1 -1
  75. package/dist/core/packet-wire.d.ts.map +1 -1
  76. package/dist/core/packet-wire.js +81 -22
  77. package/dist/core/packet-wire.js.map +1 -1
  78. package/dist/core/plurnk-uri.d.ts +2 -2
  79. package/dist/core/plurnk-uri.d.ts.map +1 -1
  80. package/dist/core/plurnk-uri.js +19 -12
  81. package/dist/core/plurnk-uri.js.map +1 -1
  82. package/dist/core/run-ops.sql +26 -26
  83. package/dist/core/scheme-types.d.ts +6 -11
  84. package/dist/core/scheme-types.d.ts.map +1 -1
  85. package/dist/core/search-gate.js +0 -0
  86. package/dist/core/search-gate.js.map +1 -1
  87. package/dist/core/token-ruler.d.ts +2 -0
  88. package/dist/core/token-ruler.d.ts.map +1 -0
  89. package/dist/core/token-ruler.js +18 -0
  90. package/dist/core/token-ruler.js.map +1 -0
  91. package/dist/core/worker-cap.d.ts +8 -0
  92. package/dist/core/worker-cap.d.ts.map +1 -0
  93. package/dist/core/worker-cap.js +20 -0
  94. package/dist/core/worker-cap.js.map +1 -0
  95. package/dist/core/{session-settings.d.ts → workspace-settings.d.ts} +5 -5
  96. package/dist/core/workspace-settings.d.ts.map +1 -0
  97. package/dist/core/{session-settings.js → workspace-settings.js} +13 -13
  98. package/dist/core/workspace-settings.js.map +1 -0
  99. package/dist/digest/Digest.d.ts +3 -3
  100. package/dist/digest/Digest.d.ts.map +1 -1
  101. package/dist/digest/Digest.js +79 -73
  102. package/dist/digest/Digest.js.map +1 -1
  103. package/dist/digest/digest.sql +18 -18
  104. package/dist/schemes/Exec.d.ts +9 -2
  105. package/dist/schemes/Exec.d.ts.map +1 -1
  106. package/dist/schemes/Exec.js +121 -68
  107. package/dist/schemes/Exec.js.map +1 -1
  108. package/dist/schemes/ExecOutputScheme.js +3 -3
  109. package/dist/schemes/ExecOutputScheme.js.map +1 -1
  110. package/dist/schemes/File.js +23 -23
  111. package/dist/schemes/File.js.map +1 -1
  112. package/dist/schemes/Known.js +5 -5
  113. package/dist/schemes/Known.js.map +1 -1
  114. package/dist/schemes/Log.d.ts.map +1 -1
  115. package/dist/schemes/Log.js +63 -14
  116. package/dist/schemes/Log.js.map +1 -1
  117. package/dist/schemes/Log.sql +49 -8
  118. package/dist/schemes/Plurnk.js +5 -5
  119. package/dist/schemes/Plurnk.js.map +1 -1
  120. package/dist/schemes/Skill.js +5 -5
  121. package/dist/schemes/Skill.js.map +1 -1
  122. package/dist/schemes/Unknown.js +5 -5
  123. package/dist/schemes/Unknown.js.map +1 -1
  124. package/dist/schemes/{Run.d.ts → Worker.d.ts} +2 -2
  125. package/dist/schemes/Worker.d.ts.map +1 -0
  126. package/dist/schemes/{Run.js → Worker.js} +67 -67
  127. package/dist/schemes/Worker.js.map +1 -0
  128. package/dist/schemes/_entry-crud.js +11 -11
  129. package/dist/schemes/_entry-crud.js.map +1 -1
  130. package/dist/schemes/_entry-crud.sql +29 -29
  131. package/dist/schemes/_entry-find.d.ts +2 -1
  132. package/dist/schemes/_entry-find.d.ts.map +1 -1
  133. package/dist/schemes/_entry-find.js +31 -19
  134. package/dist/schemes/_entry-find.js.map +1 -1
  135. package/dist/schemes/_entry-find.sql +9 -9
  136. package/dist/schemes/_entry-graph.d.ts +2 -2
  137. package/dist/schemes/_entry-graph.d.ts.map +1 -1
  138. package/dist/schemes/_entry-graph.js +18 -18
  139. package/dist/schemes/_entry-graph.js.map +1 -1
  140. package/dist/schemes/_entry-graph.sql +8 -8
  141. package/dist/schemes/_entry-manifest.d.ts +1 -1
  142. package/dist/schemes/_entry-manifest.d.ts.map +1 -1
  143. package/dist/schemes/_entry-manifest.js +74 -50
  144. package/dist/schemes/_entry-manifest.js.map +1 -1
  145. package/dist/schemes/_entry-ops.d.ts +4 -4
  146. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  147. package/dist/schemes/_entry-ops.js +25 -25
  148. package/dist/schemes/_entry-ops.js.map +1 -1
  149. package/dist/schemes/_entry-ops.sql +9 -9
  150. package/dist/schemes/_entry-semantic.d.ts +2 -1
  151. package/dist/schemes/_entry-semantic.d.ts.map +1 -1
  152. package/dist/schemes/_entry-semantic.js +23 -4
  153. package/dist/schemes/_entry-semantic.js.map +1 -1
  154. package/dist/schemes/_entry-semantic.sql +18 -4
  155. package/dist/schemes/_entry-send.d.ts +1 -1
  156. package/dist/schemes/_entry-send.d.ts.map +1 -1
  157. package/dist/schemes/_entry-send.js +6 -6
  158. package/dist/schemes/_entry-send.js.map +1 -1
  159. package/dist/schemes/exec-abort.js +1 -1
  160. package/dist/schemes/exec-abort.js.map +1 -1
  161. package/dist/server/Daemon.d.ts +50 -48
  162. package/dist/server/Daemon.d.ts.map +1 -1
  163. package/dist/server/Daemon.js +307 -297
  164. package/dist/server/Daemon.js.map +1 -1
  165. package/dist/server/client-input.js +20 -20
  166. package/dist/server/client-input.js.map +1 -1
  167. package/dist/server/dispatch-as-plurnk.d.ts +1 -1
  168. package/dist/server/dispatch-as-plurnk.d.ts.map +1 -1
  169. package/dist/server/dispatch-as-plurnk.js +7 -7
  170. package/dist/server/dispatch-as-plurnk.js.map +1 -1
  171. package/dist/server/drain.sql +27 -27
  172. package/dist/server/envelope.d.ts +20 -20
  173. package/dist/server/envelope.d.ts.map +1 -1
  174. package/dist/server/envelope.js +86 -86
  175. package/dist/server/envelope.js.map +1 -1
  176. package/dist/server/envelope.sql +39 -39
  177. package/dist/server/logEntry.d.ts +1 -1
  178. package/dist/server/logEntry.d.ts.map +1 -1
  179. package/dist/server/logEntry.js +1 -1
  180. package/dist/server/logEntry.js.map +1 -1
  181. package/dist/server/loop-model.d.ts +3 -0
  182. package/dist/server/loop-model.d.ts.map +1 -0
  183. package/dist/server/loop-model.js +26 -0
  184. package/dist/server/loop-model.js.map +1 -0
  185. package/dist/server/loopDocs.d.ts +1 -1
  186. package/dist/server/loopDocs.d.ts.map +1 -1
  187. package/dist/server/loopDocs.js +6 -6
  188. package/dist/server/loopDocs.js.map +1 -1
  189. package/dist/server/seam-entry-read.sql +2 -2
  190. package/dist/server/seam-log-read.sql +3 -3
  191. package/dist/server/seam-loop.sql +3 -3
  192. package/dist/server/seam-proposal-list.sql +4 -4
  193. package/dist/server/yolo.js +2 -2
  194. package/dist/server/yolo.js.map +1 -1
  195. package/dist/service.d.ts.map +1 -1
  196. package/dist/service.js +21 -6
  197. package/dist/service.js.map +1 -1
  198. package/migrations/0000-00-00.01_schema.sql +84 -85
  199. package/package.json +17 -14
  200. package/dist/core/TokenGauge.d.ts +0 -16
  201. package/dist/core/TokenGauge.d.ts.map +0 -1
  202. package/dist/core/TokenGauge.js +0 -58
  203. package/dist/core/TokenGauge.js.map +0 -1
  204. package/dist/core/run-cap.d.ts +0 -8
  205. package/dist/core/run-cap.d.ts.map +0 -1
  206. package/dist/core/run-cap.js +0 -20
  207. package/dist/core/run-cap.js.map +0 -1
  208. package/dist/core/search-prefetch.d.ts +0 -17
  209. package/dist/core/search-prefetch.d.ts.map +0 -1
  210. package/dist/core/search-prefetch.js +0 -164
  211. package/dist/core/search-prefetch.js.map +0 -1
  212. package/dist/core/session-settings.d.ts.map +0 -1
  213. package/dist/core/session-settings.js.map +0 -1
  214. package/dist/schemes/Run.d.ts.map +0 -1
  215. package/dist/schemes/Run.js.map +0 -1
package/SPEC.md CHANGED
@@ -14,10 +14,10 @@ Canonical meanings. When a doc, comment, test name, or commit message uses one o
14
14
 
15
15
  | Term | Meaning |
16
16
  |---|---|
17
- | **agent** | The plurnk runtime. Acts in-session as the reserved `plurnk` run (§actor-boundary self-hosting), never a privileged singleton owning its own entries (entry scope is `session` / `run`, §machine-processes). |
18
- | **session** | Durable user-named workspace. Persists across runs and process restarts. Identity: `sessions.id` + unique `sessions.name`. |
19
- | **run** | A stretch of work within a session. Multiple runs per session. May fork from another run via `parent_run_id`. Owns the log entries. |
20
- | **loop** | One model-driven or client-driven iteration within a run. Status ∈ {100 pending · 102 running · 200 done · 202 waiting (blocked on a live obligation, §send) · 413 budget-overflow · 429 turn-ceiling · 499 cancelled · 500 failed · 504 wall-clock timeout (§operator-config-loop-timeout) · 508 runaway}. Many loops per run. The model runs inside a loop; each client RPC has its own loop. |
17
+ | **agent** | The plurnk runtime. Acts in-workspace as the reserved `plurnk` worker (§actor-boundary self-hosting), never a privileged singleton owning its own entries (entry scope is `workspace` / `worker`, §machine-processes). |
18
+ | **workspace** | Durable user-named workspace. Persists across workers and process restarts. Identity: `workspaces.id` + unique `workspaces.name`. |
19
+ | **run** | A stretch of work within a workspace. Multiple workers per workspace. May fork from another worker via `parent_worker_id`. Owns the log entries. |
20
+ | **loop** | One model-driven or client-driven iteration within a worker. Status ∈ {100 pending · 102 running · 200 done · 202 waiting (blocked on a live obligation, §send) · 413 budget-overflow · 429 turn-ceiling · 499 cancelled · 500 failed · 504 wall-clock timeout (§operator-config-loop-timeout) · 508 runaway}. Many loops per worker. The model workers inside a loop; each client RPC has its own loop. |
21
21
  | **turn** | One round-trip with the LLM (or one client RPC dispatch). One assembled prompt sent, one parsed response handled. Many turns per loop. Identity: `(loop_id, sequence)`. |
22
22
  | **op** | One DSL operation the model emits. Parsed into a `PlurnkStatement`. Examples: `EDIT`, `READ`, `SEND`, `FIND`, `COPY`, `MOVE`, `OPEN`, `FOLD`, `EXEC`. One turn produces zero or more ops. |
23
23
  | **statement** | Synonym for parsed op. The AST shape `PlurnkStatement` from `@plurnk/plurnk-grammar`. |
@@ -30,7 +30,7 @@ Canonical meanings. When a doc, comment, test name, or commit message uses one o
30
30
  |---|---|
31
31
  | **entry** | The unit of canonical state. Identity: `(scope, scheme, pathname)`. Holds one or more `channels` of content plus `tags` and `attributes`. |
32
32
  | **channel** | A named content buffer on an entry. Examples: `body`, `stdout`, `stderr`, `headers`, `symbols`. Each channel has `content`, `mimetype`, `tokens`, `state`. |
33
- | **scope** | `"session"` or `"run"`. Determines who reads: session-scope entries are the shared world (every run in the session), run-scope entries are a run's private scratch (§machine-processes). |
33
+ | **scope** | `"workspace"` or `"worker"`. Determines who reads: workspace-scope entries are the shared world (every worker in the workspace), worker-scope entries are a worker's private scratch (§machine-processes). |
34
34
  | **scheme** | A URI prefix + handler. `known`, `unknown`, `file`, `https`, `exec`. The scheme handler interprets paths under its prefix and implements the op surface. Consumption surface §scheme-surface; author contract: [plurnk-schemes](https://github.com/plurnk/plurnk-schemes). |
35
35
  | **mimetype** | A channel's content type. Drives the handler that produces the structural projections (`symbols`, `deepJson`, `deepXml`). Consumption surface §mimetype-surface; author contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). |
36
36
  | **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol. Consumption surface §provider; author contract: [plurnk-providers](https://github.com/plurnk/plurnk-providers). |
@@ -88,7 +88,7 @@ Independent axes on entries and channels. Confusion across them is a recurring s
88
88
 
89
89
  ## §arch Architecture
90
90
 
91
- The ecosystem and the in-process shape (§ecosystem–§in-process), then the two invariants the rest of the spec rests on: isolation by run (§actor-boundary) and the session/run/fork ownership model (§machine-processes).
91
+ The ecosystem and the in-process shape (§ecosystem–§in-process), then the two invariants the rest of the spec rests on: isolation by worker (§actor-boundary) and the workspace/worker/fork ownership model (§machine-processes).
92
92
 
93
93
  ### §ecosystem Ecosystem
94
94
 
@@ -124,89 +124,89 @@ The grammar (`@plurnk/plurnk-grammar`) owns parser + AST contract. Schemes recei
124
124
 
125
125
  Server posture: this package is the runtime. User-facing CLI lives in `plurnk` and consumes the library API (`src/index.ts` + `PATHS`).
126
126
 
127
- ### §actor-boundary The actor boundary: isolation by run, two doors, self-hosting
127
+ ### §actor-boundary The actor boundary: isolation by worker, two doors, self-hosting
128
128
 
129
- **Question.** A session holds many runs — model, client, plurnk (§lifecycle-terms, §authority-terms) — over one shared manifest. What keeps one run's activity out of another's conversation; what are the *only* ways a run's work reaches another; and does the engine's own work obey the boundary or get a privileged back channel?
129
+ **Question.** A workspace holds many workers — model, client, plurnk (§lifecycle-terms, §authority-terms) — over one shared manifest. What keeps one worker's activity out of another's conversation; what are the *only* ways a worker's work reaches another; and does the engine's own work obey the boundary or get a privileged back channel?
130
130
 
131
- **Decision — isolation by run; the model is not privileged.** A packet renders exactly one run's log — the assembling run's — against the session's shared manifest (§packet). A run cannot see another's log: isolation is *structural*, a consequence of "a run owns its log entries" (§lifecycle-terms) and "one packet, one run," never an `origin` filter at render time. `origin` (§authority-terms) is **attribution** — the delta's provenance (§env-delta) — never read to filter a row. {§actor-boundary-isolation} {§actor-boundary-origin-not-filter}
131
+ **Decision — isolation by worker; the model is not privileged.** A packet renders exactly one worker's log — the assembling worker's — against the workspace's shared manifest (§packet). A worker cannot see another's log: isolation is *structural*, a consequence of "a worker owns its log entries" (§lifecycle-terms) and "one packet, one worker," never an `origin` filter at render time. `origin` (§authority-terms) is **attribution** — the delta's provenance (§env-delta) — never read to filter a row. {§actor-boundary-isolation} {§actor-boundary-origin-not-filter}
132
132
 
133
- **Two doors, and only two.** A run's work reaches another run by exactly two channels, and a private log is reachable no other way:
134
- - the **environment door** — a write to a *shared entry* surfaces to every run sharing it as a folded, attributed delta (§env-delta). *State.*
135
- - the **voice door** — an **inject** delivers a turn into a *specific* run's log; `btw` is the user's mid-loop inject. *Message.*
133
+ **Two doors, and only two.** A worker's work reaches another worker by exactly two channels, and a private log is reachable no other way:
134
+ - the **environment door** — a write to a *shared entry* surfaces to every worker sharing it as a folded, attributed delta (§env-delta). *State.*
135
+ - the **voice door** — an **inject** delivers a turn into a *specific* worker's log; `btw` is the user's mid-loop inject. *Message.*
136
136
 
137
137
  {§actor-boundary-two-doors}
138
138
 
139
- **Wild west — no mutual exclusion.** Runs share the manifest without locks. Coordination is cooperative (tags + the shared workspace convention) and softly fenced (the §membership `read-only` overlay, a session policy, bounds every run's writable surface uniformly — §machine-processes); a conflict *surfaces* as a delta rather than being prevented. Inform, never override. {§actor-boundary-no-mutex}
139
+ **Wild west — no mutual exclusion.** Runs share the manifest without locks. Coordination is cooperative (tags + the shared workspace convention) and softly fenced (the §membership `read-only` overlay, a workspace policy, bounds every worker's writable surface uniformly — §machine-processes); a conflict *surfaces* as a delta rather than being prevented. Inform, never override. {§actor-boundary-no-mutex}
140
140
 
141
- **Passive wake.** An idle run wakes on exactly two events, both *directed at the run*: a prompt injected into it — the **voice door** (a user/system `loop.inject`, and once `run://` lands a sibling's `SEND(run://<name>)`) — or a **stream-status transition** on a subscription it opened (§channel-state). Everything ambient is a delta — a sibling's edit to a shared entry, an out-of-band disk change — and a delta **never** wakes; it queues and drains at the next turn one of those two events produces (§env-delta). {§actor-boundary-passive-wake}
141
+ **Passive wake.** An idle worker wakes on exactly two events, both *directed at the worker*: a prompt injected into it — the **voice door** (a user/system `loop.inject`, and once `worker://` lands a sibling's `SEND(worker://<name>)`) — or a **stream-status transition** on a subscription it opened (§channel-state). Everything ambient is a delta — a sibling's edit to a shared entry, an out-of-band disk change — and a delta **never** wakes; it queues and drains at the next turn one of those two events produces (§env-delta). {§actor-boundary-passive-wake}
142
142
 
143
- **Self-hosting — the runtime is an actor, not a back channel.** Runtime-initiated work (fs reconciliation §membership, git auto-add) is an **ephemeral `plurnk` run** firing ordinary ops, seen by other runs through the environment door like any actor's — not a privileged engine pathway. The engine keeps only the irreducible kernel runs stand on (spawn, dispatch, packet assembly, the budget rails §grinder, the fs-watch); everything expressible as ops on session entries is a run doing ops, through the same `op.*` surface (§methods) the service offers clients. Dogfooding is the architecture, not a test mode. {§actor-boundary-self-hosting}
143
+ **Self-hosting — the runtime is an actor, not a back channel.** Runtime-initiated work (fs reconciliation §membership, git auto-add) is an **ephemeral `plurnk` worker** firing ordinary ops, seen by other workers through the environment door like any actor's — not a privileged engine pathway. The engine keeps only the irreducible kernel workers stand on (spawn, dispatch, packet assembly, the budget rails §grinder, the fs-watch); everything expressible as ops on workspace entries is a worker doing ops, through the same `op.*` surface (§methods) the service offers clients. Dogfooding is the architecture, not a test mode. {§actor-boundary-self-hosting}
144
144
 
145
- **Migration path.** Largely realized: `Engine.dispatch` is origin-agnostic; client ops run in a per-connection client loop (`_dispatchAsClient`); plurnk EDITs already carry `origin=plurnk`. The keystone is **built** — `dispatchAsPlurnk` spawns the session's reserved `plurnk` run and fires ops through dispatch, mirroring `_dispatchAsClient`; its uses so far (operator docs below; the fs-divergence narration) land in the plurnk run's log. The line that remains is one of *kind*, not a list of pending dispatches: work **expressible as an op** belongs on the keystone; work that is **not** stays kernel. Disk→entry materialization is the latter — *ingestion* is the inverse of an EDIT (which proposes egress to disk, §membership-edit-membership-gate), so it has no actor op and remains fs-watch kernel, paired with the plurnk run's filtered `source=file` narration (§env-delta) so a sibling pulls only true divergences, not every re-sync; the manifest build is likewise the per-turn derivation pump — packet-assembly kernel, not an entry-creating op. The one outstanding *expressible* piece is **git auto-add** — a model-created file surfaced as a plurnk-run op — gated on the §membership repo-overlay still being built.
145
+ **Migration path.** Largely realized: `Engine.dispatch` is origin-agnostic; client ops run in a per-connection client loop (`_dispatchAsClient`); plurnk EDITs already carry `origin=plurnk`. The keystone is **built** — `dispatchAsPlurnk` spawns the workspace's reserved `plurnk` worker and fires ops through dispatch, mirroring `_dispatchAsClient`; its uses so far (operator docs below; the fs-divergence narration) land in the plurnk worker's log. The line that remains is one of *kind*, not a list of pending dispatches: work **expressible as an op** belongs on the keystone; work that is **not** stays kernel. Disk→entry materialization is the latter — *ingestion* is the inverse of an EDIT (which proposes egress to disk, §membership-edit-membership-gate), so it has no actor op and remains fs-watch kernel, paired with the plurnk worker's filtered `source=file` narration (§env-delta) so a sibling pulls only true divergences, not every re-sync; the manifest build is likewise the per-turn derivation pump — packet-assembly kernel, not an entry-creating op. The one outstanding *expressible* piece is **git auto-add** — a model-created file surfaced as a plurnk-worker op — gated on the §membership repo-overlay still being built.
146
146
 
147
- **The keystone's first use: operator reference docs.** `PLURNK_SERVICE_MD_<ALIAS>=<path>` (§operator-config) materializes `<path>` as a `plurnk:///<ALIAS>.md` entry — a `dispatchAsPlurnk` EDIT in the plurnk run, **not** the model's — and the model's turn-0 foists a READ of it. The model reads the doc inline while the materializing EDIT stays out of its log: idiomatic context injection, an ordinary entry + READ rather than a bespoke packet section. The same `PLURNK_SERVICE_MD_*` convention cascades to clients. {§actor-boundary-doc-injection}
147
+ **The keystone's first use: operator reference docs.** `PLURNK_SERVICE_MD_<ALIAS>=<path>` (§operator-config) materializes `<path>` as a `plurnk:///<ALIAS>.md` entry — a `dispatchAsPlurnk` EDIT in the plurnk worker, **not** the model's — and the model's turn-0 foists a READ of it. The model reads the doc inline while the materializing EDIT stays out of its log: idiomatic context injection, an ordinary entry + READ rather than a bespoke packet section. The same `PLURNK_SERVICE_MD_*` convention cascades to clients. {§actor-boundary-doc-injection}
148
148
 
149
- **Catalog preview.** `PLURNK_SERVICE_FILES_ITEMS` foists a turn-0 `FIND(scheme:///**)` per scheme into the run's first turn (the same plurnk-origin foist as the docs), so a run opens with its catalog instead of blank. The model's own surface — `known`/`unknown` (memory), `run` (scratch), `plurnk` (docs) — always foists **full**; the first-`N` cap applies **only to `file`** (the **bare** `FIND(**)` — the project-relative path shape plurnk.md teaches — over the external, arbitrarily-large tracked-file tree), so the model's own memory is never truncated (a partial view of memory reads as withheld). `-1` = everything full; a positive `N` = the file list capped to its first `N` (FIND's `<L>`, clamped so the strict marker never 416s; memory still full); unset / `0` = no preview (the model FINDs on demand). `log://` is absent — present-mode (the `# Log` section), not a catalog scheme. {§actor-boundary-catalog-preview}
149
+ **Catalog preview.** `PLURNK_SERVICE_FILES_ITEMS` foists a turn-0 `FIND(scheme:///**)` per scheme into the worker's first turn (the same plurnk-origin foist as the docs), so a worker opens with its catalog instead of blank. The model's own surface — `known`/`unknown` (memory), `worker` (scratch), `plurnk` (docs) — always foists **full**; the first-`N` cap applies **only to `file`** (the **bare** `FIND(**)` — the project-relative path shape plurnk.md teaches — over the external, arbitrarily-large tracked-file tree), so the model's own memory is never truncated (a partial view of memory reads as withheld). `-1` = everything full; a positive `N` = the file list capped to its first `N` (FIND's `<L>`, clamped so the strict marker never 416s; memory still full); unset / `0` = no preview (the model FINDs on demand). `log://` is absent — present-mode (the `# Log` section), not a catalog scheme. {§actor-boundary-catalog-preview}
150
150
 
151
- ### §machine-processes The machine and its processes: session, run, fork
151
+ ### §machine-processes The machine and its processes: workspace, run, fork
152
152
 
153
- **Question.** §actor-boundary isolates runs and lets the runtime self-host, but it stands on an ownership model it never states: what does a *session* own versus a *run*; what is shared versus private; and what does a fork carry? Unstated, the downstream questions — which run `log.read` reads, what a fork copies, where a per-client window onto the workspace would live — grow subtle, then metastasize. Drawn once, they vanish.
153
+ **Question.** §actor-boundary isolates workers and lets the runtime self-host, but it stands on an ownership model it never states: what does a *workspace* own versus a *worker*; what is shared versus private; and what does a fork carry? Unstated, the downstream questions — which worker `log.read` reads, what a fork copies, where a per-client window onto the workspace would live — grow subtle, then metastasize. Drawn once, they vanish.
154
154
 
155
- **Decision — the session is the world; a run is a log on it.** A **session** is the world: one shared filesystem — the `session`-scoped entries, surfaced as the per-scheme catalog (`FIND(scheme:///**)`, §packet) — under one membership overlay (§membership). Exactly one filesystem and one overlay per session; neither is per-run. A **run** is a process whose private memory is its **log** (§lifecycle-terms) — its loops, turns, and rows, each row carrying its own content, attribution (`origin`/`source`, §env-delta), and fold-state (`expanded`). A run owns **no membership**; even its visibility is not a possession but a bit on its own rows. It is a *history over the shared world, not a world*.
155
+ **Decision — the workspace is the world; a worker is a log on it.** A **workspace** is the world: one shared filesystem — the `workspace`-scoped entries, surfaced as the per-scheme catalog (`FIND(scheme:///**)`, §packet) — under one membership overlay (§membership). Exactly one filesystem and one overlay per workspace; neither is per-worker. A **worker** is a process whose private memory is its **log** (§lifecycle-terms) — its loops, turns, and rows, each row carrying its own content, attribution (`origin`/`source`, §env-delta), and fold-state (`expanded`). A worker owns **no membership**; even its visibility is not a possession but a bit on its own rows. It is a *history over the shared world, not a world*.
156
156
 
157
- **One filesystem.** The entries are the session's: `entries.session_id`, never a run. A write by any run is a write to the one filesystem every run reads; there is no per-run entry set. {§machine-processes-one-filesystem}
157
+ **One filesystem.** The entries are the workspace's: `entries.workspace_id`, never a worker. A write by any worker is a write to the one filesystem every worker reads; there is no per-worker entry set. {§machine-processes-one-filesystem}
158
158
 
159
- **One overlay.** Membership — `git ls-files ∪ pick − hide` with `view` read-only (§membership) — is the session's: `session_constraints.session_id`, never a run. It is workspace *curation*, and the workspace *is* the session; two runs are two conversations about one curated workspace and see the same one. Divergent membership is a different session, never a per-run overlay. {§machine-processes-one-overlay}
159
+ **One overlay.** Membership — `git ls-files ∪ pick − hide` with `view` read-only (§membership) — is the workspace's: `workspace_constraints.workspace_id`, never a worker. It is workspace *curation*, and the workspace *is* the workspace; two workers are two conversations about one curated workspace and see the same one. Divergent membership is a different workspace, never a per-worker overlay. {§machine-processes-one-overlay}
160
160
 
161
- **A run's memory of the world is its log — no shadow beside it.** A run's view of the shared world is the log and only the log — never a per-run snapshot. *What I am looking at* (OPEN/FOLD) is `log_entries.expanded`, a bit on the run's own rows, toggled by ordinary `log:///` ops — not a second store, and never membership (§open-fold). *What I last saw* needs no shadow either: a run learns its world moved through log entries (§env-delta) — a sibling's write broadcast into its log, an out-of-band disk change detected against the entry's own content and broadcast the same way — never through a per-run snapshot the run cannot see. (Its private **scratch** — run-scope entries, §run-scheme — is the run's own evolving workspace, owned not shadowed: a store it writes and reads deliberately, not a hidden mirror of the shared world. The doctrine is *no shadow of the world*, not *no private state*.) {§machine-processes-run-is-its-log}
161
+ **A worker's memory of the world is its log — no shadow beside it.** A worker's view of the shared world is the log and only the log — never a per-worker snapshot. *What I am looking at* (OPEN/FOLD) is `log_entries.expanded`, a bit on the worker's own rows, toggled by ordinary `log:///` ops — not a second store, and never membership (§open-fold). *What I last saw* needs no shadow either: a worker learns its world moved through log entries (§env-delta) — a sibling's write broadcast into its log, an out-of-band disk change detected against the entry's own content and broadcast the same way — never through a per-worker snapshot the worker cannot see. (Its private **scratch** — worker-scope entries, §worker-scheme — is the worker's own evolving workspace, owned not shadowed: a store it writes and reads deliberately, not a hidden mirror of the shared world. The doctrine is *no shadow of the world*, not *no private state*.) {§machine-processes-worker-is-its-log}
162
162
 
163
- **A run's log is private to packets, not to the session.** Isolation (§actor-boundary) governs what an *actor* sees — its own run, never a sibling's. It does not wall off the *wire*: any connection may read any run's log in its session by id — `log.read({ runId })`, ownership-verified, defaulting to the connection's own run. This is how a conversation client reads the **model** run, where the conversation lives: `loop.run` returns its `modelRunId`, and `session.runs` enumerates a session's runs for a connection that did not drive it live. The read is observation, never packet membership — no actor sees it. {§machine-processes-model-run-readable}
163
+ **A worker's log is private to packets, not to the workspace.** Isolation (§actor-boundary) governs what an *actor* sees — its own worker, never a sibling's. It does not wall off the *wire*: any connection may read any worker's log in its workspace by id — `log.read({ workerId })`, ownership-verified, defaulting to the connection's own worker. This is how a conversation client reads the **model** worker, where the conversation lives: `loop.run` returns its `modelWorkerId`, and `workspace.workers` enumerates a workspace's workers for a connection that did not drive it live. The read is observation, never packet membership — no actor sees it. {§machine-processes-model-worker-readable}
164
164
 
165
- **A run carries its actor.** Each run records its `origin` — `model` (the conversation), `client` (a connection's own run), or `plurnk` (the runtime's self-hosting run) — set once at creation and inherited by a fork. `session.runs` returns it, so a conversation client identifies the model run by its actor, not by parsing the name — which is set at instantiation and immutable, never renamed (a run is permanent history, §machine-processes-run-is-its-log). {§machine-processes-run-origin}
165
+ **A worker carries its actor.** Each worker records its `origin` — `model` (the conversation), `client` (a connection's own worker), or `plurnk` (the runtime's self-hosting worker) — set once at creation and inherited by a fork. `workspace.workers` returns it, so a conversation client identifies the model worker by its actor, not by parsing the name — which is set at instantiation and immutable, never renamed (a worker is permanent history, §machine-processes-worker-is-its-log). {§machine-processes-worker-origin}
166
166
 
167
- **Fork — copy the log, share the world.** A fork is a new run in the *same* session (`runs.parent_run_id`, §lifecycle-terms). It copies the **log** — the rows, their fold-state riding along — so the branch inherits everything the parent observed (§env-delta makes a run's timeline self-contained for exactly this) and diverges freely after. {§machine-processes-fork-copies-the-log} It shares the **world** — the one filesystem, the one overlay — live and uncopied, because the run never owned it. {§machine-processes-fork-shares-the-world}
167
+ **Fork — copy the log, share the world.** A fork is a new worker in the *same* workspace (`workers.parent_worker_id`, §lifecycle-terms). It copies the **log** — the rows, their fold-state riding along — so the branch inherits everything the parent observed (§env-delta makes a worker's timeline self-contained for exactly this) and diverges freely after. {§machine-processes-fork-copies-the-log} It shares the **world** — the one filesystem, the one overlay — live and uncopied, because the worker never owned it. {§machine-processes-fork-shares-the-world}
168
168
 
169
- **A session cannot be forked.** There is nothing to branch — a session *is* the shared ground. `runs` carries `parent_run_id`; `sessions` carries no parent. Parallel histories over one workspace are forks of its runs; a divergent workspace is a new session. {§machine-processes-no-fork-session}
169
+ **A workspace cannot be forked.** There is nothing to branch — a workspace *is* the shared ground. `workers` carries `parent_worker_id`; `workspaces` carries no parent. Parallel histories over one workspace are forks of its workers; a divergent workspace is a new workspace. {§machine-processes-no-fork-workspace}
170
170
 
171
- **Rationale.** The model falls out of one correction: *a run is a history over a shared world, not a world.* Entries are the world (session); the log is the history (run); forking a history need not copy the world, and a run accumulates nothing the log does not already hold. The overlay's session home is forced the same way — it is the world's curation, and the world is shared; per-run it fragments the one manifest, forks the membership read-gate (the §membership security line), and duplicates what FOLD already does at the right level. Every "which run / what's copied / where's the per-client window" answers itself once the world/log line is drawn.
171
+ **Rationale.** The model falls out of one correction: *a worker is a history over a shared world, not a world.* Entries are the world (workspace); the log is the history (worker); forking a history need not copy the world, and a worker accumulates nothing the log does not already hold. The overlay's workspace home is forced the same way — it is the world's curation, and the world is shared; per-worker it fragments the one manifest, forks the membership read-gate (the §membership security line), and duplicates what FOLD already does at the right level. Every "which worker / what's copied / where's the per-client window" answers itself once the world/log line is drawn.
172
172
 
173
- **Migration path.** Mostly stating what the schema already carries: `runs.parent_run_id` and the parentless `sessions` exist (§lifecycle-terms); `session_constraints` is session-level (§membership); §env-delta already makes a run's timeline self-contained, so a fork's log copy suffices. Additive: `run.fork` over the wire (the engine fork is built). Two repatriations: §actor-boundary's "read-only overlay scopes a run's writable surface" becomes a *session* policy bounding every run uniformly; and the §env-delta environment door has shed its per-run snapshot — a run's only memory is its log, so drift is pulled from the shared log (other actors' edits since the run's last turn) and the filesystem narrates its own through the `plurnk` run, both already log entries, never a per-run shadow.
173
+ **Migration path.** Mostly stating what the schema already carries: `workers.parent_worker_id` and the parentless `workspaces` exist (§lifecycle-terms); `workspace_constraints` is workspace-level (§membership); §env-delta already makes a worker's timeline self-contained, so a fork's log copy suffices. Additive: `worker.fork` over the wire (the engine fork is built). Two repatriations: §actor-boundary's "read-only overlay scopes a worker's writable surface" becomes a *workspace* policy bounding every worker uniformly; and the §env-delta environment door has shed its per-worker snapshot — a worker's only memory is its log, so drift is pulled from the shared log (other actors' edits since the worker's last turn) and the filesystem narrates its own through the `plurnk` worker, both already log entries, never a per-worker shadow.
174
174
 
175
- ### §run-scheme The run:// scheme — control (spawn, irc, fork, terminate, cap, collect) and run-scope scratch
175
+ ### §worker-scheme The worker:// scheme — control (spawn, irc, fork, terminate, cap, collect) and worker-scope scratch
176
176
 
177
- The run:// scheme makes §machine-processes addressable: a `run://` target is a sister run in the session — `run://self` the current run, `run://<name>` a session-scoped sibling (`runs.name`). `self` is the reserved current-run sentinel; empty authority (`run:///`) is invalid (400). Same-session only; a run never addresses another session's runs (§actor-boundary). The path discriminates the two faces: **path-absent is control** on the run-as-actor — the NAME is the authority (`run://<name>`, two slashes, no path) — while **path-present is run-scope storage**: `run://<owner>/<path>` (`run://self/<path>` for self) addresses the owner's private scratch (Scratch + Perspective, below). The control ops are three, fire-and-forget — the child runs independently, lineage in `runs.parent_run_id`:
177
+ The worker:// scheme makes §machine-processes addressable: a `worker://` target is a sister worker in the workspace — `worker://self` the current worker, `worker://<name>` a workspace-scoped sibling (`workers.name`). `self` is the reserved current-worker sentinel; empty authority (`worker:///`) is invalid (400). Same-workspace only; a worker never addresses another workspace's workers (§actor-boundary). The path discriminates the two faces: **path-absent is control** on the worker-as-actor — the NAME is the authority (`worker://<name>`, two slashes, no path) — while **path-present is worker-scope storage**: `worker://<owner>/<path>` (`worker://self/<path>` for self) addresses the owner's private scratch (Scratch + Perspective, below). The control ops are three, fire-and-forget — the child workers independently, lineage in `workers.parent_worker_id`:
178
178
 
179
- - **Spawn** — `WORK(run://<name>):task` creates a new worker sister (empty log) and starts it with `task` on its first loop. WORK/FORK are the run-creation verbs (grammar 0.74.55): EDIT is file/entry only, so EDIT on the bare run entity is a **400** steering to WORK/FORK — the entity is not an entry. A name is **frozen per run** but **reclaimable across time** (§machine-processes-run-origin): a name held only by a *terminated* sister is free to reuse — a fresh spawn takes a new row and `run_resolve_by_name` resolves the newest, the corpse keeping its name in permanent history. A name a *live* sister still holds is a conflict — **409 `run '<name>' is already running`**, legible at the spawn gate, never a raw store-level uniqueness error. {§run-scheme-spawn}
180
- - **irc** — `SEND(run://<name>):msg` delivers `msg` to an existing sister, the **voice door** (§actor-boundary-two-doors): an active sister folds it into its next turn, an idle one wakes (§actor-boundary-passive-wake); a name with no run in the session is 404. {§run-scheme-irc}
181
- - **Fork** — `FORK(run://<name>):task` branches the current run into a **named** sister: its log is deep-copied (§machine-processes-fork-copies-the-log), which continues with `task`; the world is shared, never copied (§machine-processes-fork-shares-the-world). A fork ALSO inherits the run-scope **scratch** — its private workspace deep-copied with the owner remapped (source → branch) — so the branch opens with the parent's notes and diverges on its own edits: *fork = everything-in-common-but-name*. WORK and FORK are distinct verbs — WORK spawns a fresh worker, FORK branches the log — and each names the new run explicitly, so the model addresses it (`KILL`/`SEND`/`READ`) by that name. The legacy auto-name `<parent>-fork-<N>` remains only the internal fallback when Fork is invoked without a name. Inherited loops are copied as **terminal history** (a non-terminal status is clamped): a fork's own work is a fresh loop, so an inherited mid-flight loop never makes the branch look forever-live to the §send-premature-terminate gate. {§run-scheme-fork} {§run-scheme-fork-scratch}
182
- - **Delegation inherits authority.** The live loop a spawn, fork, or irc-raised fresh loop starts with carries the **delegating loop's flags** — a YOLO parent delegates YOLO workers. Flags are a property of the delegation, not of the client connection: a child loop that fell back to defaults would propose every side-effecting op into a resolver-less void (nobody attends a headless worker's review queue; each attempt burns the full proposal timeout — the four-sweep fan-out wedge, where three workers stalled 300s-per-EXEC while the parent slept and the harness watched only the parent). An irc that *resumes* a parked loop leaves that loop's own flags untouched — inheritance applies only where a fresh loop is born. {§run-delegation-inherits-flags}
183
- - **A wake re-queue is not a terminal.** A conclusion-wake resumes a 202-blocked loop by re-queueing it (202 → 100); when that lands while the loop's OWN live drain is between turns, the drain **re-claims and continues** (atomic 100 → 102; the injected prompt is already the next turn) — it never reports the re-queue outward. Treating 100 as an externally-imposed terminal broadcast a QUEUED loop as `loop/terminated {finalStatus: 100}` while the DB healed to 200 behind it — a client-facing lie the delegation topology hit on ~30% of runs. {§run-lifecycle-wake-requeue-not-terminal}
179
+ - **Spawn** — `WORK(worker://<name>):task` creates a new worker sister (empty log) and starts it with `task` on its first loop. WORK/FORK are the worker-creation verbs (grammar 0.74.55): EDIT is file/entry only, so EDIT on the bare worker entity is a **400** steering to WORK/FORK — the entity is not an entry. A name is **frozen per worker** but **reclaimable across time** (§machine-processes-worker-origin): a name held only by a *terminated* sister is free to reuse — a fresh spawn takes a new row and `worker_resolve_by_name` resolves the newest, the corpse keeping its name in permanent history. A name a *live* sister still holds is a conflict — **409 `worker '<name>' is already running`**, legible at the spawn gate, never a raw store-level uniqueness error. {§worker-scheme-spawn}
180
+ - **irc** — `SEND(worker://<name>):msg` delivers `msg` to an existing sister, the **voice door** (§actor-boundary-two-doors): an active sister folds it into its next turn, an idle one wakes (§actor-boundary-passive-wake); a name with no worker in the workspace is 404. {§worker-scheme-irc}
181
+ - **Fork** — `FORK(worker://<name>):task` branches the current worker into a **named** sister: its log is deep-copied (§machine-processes-fork-copies-the-log), which continues with `task`; the world is shared, never copied (§machine-processes-fork-shares-the-world). A fork ALSO inherits the worker-scope **scratch** — its private workspace deep-copied with the owner remapped (source → branch) — so the branch opens with the parent's notes and diverges on its own edits: *fork = everything-in-common-but-name*. WORK and FORK are distinct verbs — WORK spawns a fresh worker, FORK branches the log — and each names the new worker explicitly, so the model addresses it (`KILL`/`SEND`/`READ`) by that name. The legacy auto-name `<parent>-fork-<N>` remains only the internal fallback when Fork is invoked without a name. Inherited loops are copied as **terminal history** (a non-terminal status is clamped): a fork's own work is a fresh loop, so an inherited mid-flight loop never makes the branch look forever-live to the §send-premature-terminate gate. {§worker-scheme-fork} {§worker-scheme-fork-scratch}
182
+ - **Delegation inherits authority.** The live loop a spawn, fork, or irc-raised fresh loop starts with carries the **delegating loop's flags** — a YOLO parent delegates YOLO workers. Flags are a property of the delegation, not of the client connection: a child loop that fell back to defaults would propose every side-effecting op into a resolver-less void (nobody attends a headless worker's review queue; each attempt burns the full proposal timeout — the four-sweep fan-out wedge, where three workers stalled 300s-per-EXEC while the parent slept and the harness watched only the parent). An irc that *resumes* a parked loop leaves that loop's own flags untouched — inheritance applies only where a fresh loop is born. {§worker-delegation-inherits-flags}
183
+ - **A wake re-queue is not a terminal.** A conclusion-wake resumes a 202-blocked loop by re-queueing it (202 → 100); when that lands while the loop's OWN live drain is between turns, the drain **re-claims and continues** (atomic 100 → 102; the injected prompt is already the next turn) — it never reports the re-queue outward. Treating 100 as an externally-imposed terminal broadcast a QUEUED loop as `loop/terminated {finalStatus: 100}` while the DB healed to 200 behind it — a client-facing lie the delegation topology hit on ~30% of runs. {§worker-lifecycle-wake-requeue-not-terminal}
184
184
 
185
- All three ride one engine seam — the daemon's inject (active→fold, idle→enqueue+drain) — so the handler creates/branches the run and hands off; the daemon owns provider + system prompt. FORK/WORK carry the seed task in the body and are their own ops (grammar 0.74.55), dispatched to run control — never the entry-copy path.
185
+ All three ride one engine seam — the daemon's inject (active→fold, idle→enqueue+drain) — so the handler creates/branches the worker and hands off; the daemon owns provider + system prompt. FORK/WORK carry the seed task in the body and are their own ops (grammar 0.74.55), dispatched to worker control — never the entry-copy path.
186
186
 
187
187
  Beyond the three creation ops:
188
188
 
189
- - **Scratch (storage)** — `run://<owner>/<path>` is run-scope entry storage (`scope='run'`, the owner is the run name folded into the path; `run://self` is self). A run EDITs only its own scratch — a cross-run write is **403** ("read a sister's notes, never write them") — but READs and FINDs any sister's by name (cross-run read is open; scratch is perspective-private, not ACL-private). {§run-scheme-scratch}
190
- - **Scratch KILL (delete)** — `KILL(run://<owner>/<path>)` with an entry **path present** deletes that scratch entry (200; 404 if absent), self-only like EDIT (a cross-run delete is **403**) — the model's curation lever over its own workspace, distinct from the path-ABSENT `KILL(run://<name>)` which terminates the runrun-scheme-terminate). The discriminator is the entry path, never the op. {§run-scheme-scratch-kill}
191
- - **Perspective** — a run's own scratch is catalogued in **its** manifest alone — `Manifest(run) = session-scope ∪ this-run's-run-scope`, foisted as `FIND(run://self/**)` at turn 0 — so a sibling reaches it only by explicit `FIND(run://<name>/**)` and never sees it in its own perspective; isolation is structural (`scope='run'` is excluded from every session query, the owner opted back in only on its own read paths). {§run-scheme-find-perspective}
192
- - **Terminate** — `KILL(run://<name>)` aborts a run by address (self is `run://self`): its active loop closes 499 and its subscriptions tear down; a name with no run is 404. The override to the fire-and-forget default — not a parent-power, whoever holds the address may end it; a run left alone simply ends at its own `SEND[200]`. {§run-scheme-terminate}
193
- - **Cap** — `PLURNK_SERVICE_SESSION_RUNS_MAX_ACTIVE` ceilings the *concurrent* active runs per session (a run with a non-terminal loop); a spawn or fork past it fails hard (508 — no queue, no retry), irc exempt; `-1` disables it. The fork-bomb brake, sized for sessions that live for months. {§run-scheme-cap}
194
- - **Collect** — a run's loop reaching a terminal status surfaces to its sisters as an ambient delta (§env-delta): a `SEND` from `run://<name>` carrying the loop's deliverable — the `SEND[200]` body, or for an abandonment the reason. A **2xx deliverable is born OPEN** (its body materialized into the parent's packet, not hidden behind a fold): a child's success must reach the parent open and awakening, never a bodyless row. An abandonment (non-2xx) surfaces folded. Every death-path is stamped uniformly, so no termination is silent; collection is the shared world moving, never a verb. **Child orientation.** Beyond the conclusion delta, every turn the system packet surfaces the live things THIS run *currently holds* — open streams (`## Plurnk Service Child Streams`) and unconcluded child runs (`## Plurnk Service Child Runs`) — as terse `* <status> <path>` pointers (the same shape as the errors section), just above it. A worker is otherwise marked only at spawn and at conclusion; in between it goes silent, so a model loses track of what it holds and premature-terminates. This is ORIENTING STATE, never advice: the model SEES its live subtree (`* 102 run://worker-x`, `* active sh:///1/2/3`) and reasons for itself — READ/OPEN/KILL via the path — the error stays terse. Empty → omitted, like errors. {§child-orientation} The **pull** side mirrors the push: a path-absent `READ(run://<name>)` collects the same deliverable on demand — the latest loop's terminal message (the result, or the abandonment reason) for a concluded run; a run **still running** has not delivered, so the READ returns **425** (Too Early) and the turn's `SEND[202]` **blocks the loop on the join** ({§join-blocking-collect}) until the worker delivers — the engine holds the join, the model never drives a park. A missing name is 404. So the model never needs to guess a scratch path to "check on" a worker — reading the run itself yields its outcome or a wait. {§run-scheme-collect}
189
+ - **Scratch (storage)** — `worker://<owner>/<path>` is worker-scope entry storage (`scope='worker'`, the owner is the worker name folded into the path; `worker://self` is self). A worker EDITs only its own scratch — a cross-worker write is **403** ("read a sister's notes, never write them") — but READs and FINDs any sister's by name (cross-worker read is open; scratch is perspective-private, not ACL-private). {§worker-scheme-scratch}
190
+ - **Scratch KILL (delete)** — `KILL(worker://<owner>/<path>)` with an entry **path present** deletes that scratch entry (200; 404 if absent), self-only like EDIT (a cross-worker delete is **403**) — the model's curation lever over its own workspace, distinct from the path-ABSENT `KILL(worker://<name>)` which terminates the workerworker-scheme-terminate). The discriminator is the entry path, never the op. {§worker-scheme-scratch-kill}
191
+ - **Perspective** — a worker's own scratch is catalogued in **its** manifest alone — `Manifest(worker) = workspace-scope ∪ this-worker's-worker-scope`, foisted as `FIND(worker://self/**)` at turn 0 — so a sibling reaches it only by explicit `FIND(worker://<name>/**)` and never sees it in its own perspective; isolation is structural (`scope='worker'` is excluded from every workspace query, the owner opted back in only on its own read paths). {§worker-scheme-find-perspective}
192
+ - **Terminate** — `KILL(worker://<name>)` aborts a worker by address (self is `worker://self`): its active loop closes 499 and its subscriptions tear down; a name with no worker is 404. The override to the fire-and-forget default — not a parent-power, whoever holds the address may end it; a worker left alone simply ends at its own `SEND[200]`. {§worker-scheme-terminate}
193
+ - **Cap** — `PLURNK_SERVICE_WORKSPACE_WORKERS_MAX_ACTIVE` ceilings the *concurrent* active workers per workspace (a worker with a non-terminal loop); a spawn or fork past it fails hard (508 — no queue, no retry), irc exempt; `-1` disables it. The fork-bomb brake, sized for workspaces that live for months. {§worker-scheme-cap}
194
+ - **Collect** — a worker's loop reaching a terminal status surfaces to its sisters as an ambient delta (§env-delta): a `SEND` from `worker://<name>` carrying the loop's deliverable — the `SEND[200]` body, or for an abandonment the reason. A **2xx deliverable is born OPEN** (its body materialized into the parent's packet, not hidden behind a fold): a child's success must reach the parent open and awakening, never a bodyless row. An abandonment (non-2xx) surfaces folded. Every death-path is stamped uniformly, so no termination is silent; collection is the shared world moving, never a verb. **Child orientation.** Beyond the conclusion delta, every turn the system packet surfaces the live things THIS worker *currently holds* — open streams (`## Child Streams`) and unconcluded child workers (`## Active Child Workers`) — as terse `* <status> <path>` pointers (the same shape as the errors section), just above it. A worker is otherwise marked only at spawn and at conclusion; in between it goes silent, so a model loses track of what it holds and premature-terminates. This is ORIENTING STATE, never advice: the model SEES its live subtree (`* 102 worker://worker-x`, `* active sh:///1/2/3`) and reasons for itself — READ/OPEN/KILL via the path — the error stays terse. Empty → omitted, like errors. {§child-orientation} The **pull** side mirrors the push: a path-absent `READ(worker://<name>)` collects the same deliverable on demand — the latest loop's terminal message (the result, or the abandonment reason) for a concluded worker; a worker **still running** has not delivered, so the READ returns **425** (Too Early) and the turn's `SEND[202]` **blocks the loop on the join** ({§join-blocking-collect}) until the worker delivers — the engine holds the join, the model never drives a park. A missing name is 404. So the model never needs to guess a scratch path to "check on" a worker — reading the worker itself yields its outcome or a wait. {§worker-scheme-collect}
195
195
 
196
196
  ### §run-lifecycle Run lifecycle: the drain, the reap, the passive wake
197
197
 
198
- - **A `READ` on a running child is a blocking join, not a poll.** {§join-blocking-collect} Fork-join theory (`ARCHITECTURE.md`): collecting a child blocks until it completes; a non-blocking try-join that returns "not ready, come back" invites a busy-wait, which the cycle detector then strikes (the topo degenerate-win: a parent spin-waited on `SEND[102]` for 8 turns and struck out at 508 while both its workers succeeded). So a path-absent `READ(run://<running-child>)` returns **425** (Too Early) and the child is now a **live obligation** on the loop; the turn's `SEND[202]` **blocks on the join** (§wait-obligation-matrix, `status 202`) instead of spinning. On the child's terminal (ANY status — the collect-delta pushes success open, abandonment folded) the join **reawakens the same loop** with the result in its log, its prompt intact. The model writes straight-line "spawn, then READ the result"; the engine hides the scheduler (a blocking `read()`), so worker-await needs no park syntax and no teaching — the concurrency lives in the kernel. **Liveness** rides the existing guarantee: children are bounded (their own maxTurns/strikes → a guaranteed terminal in the terminal set), the join wakes on any terminal incl. failure, and the owed-wake covers the finished-before-parked race — so the block cannot deadlock. Any `SEND` clears the per-turn arm; a `SEND[200]` terminal with a live child stays the existing premature-terminate steer (a terminal carries the final answer, so it must collect-then-conclude, never auto-join). The **default is happy and safe** — bare `READ` blocks indefinitely (safe by boundedness); a `<seconds>` timeout-poll (425 as its expiry) is the opt-in expert path.
198
+ - **A `READ` on a running child is a blocking join, not a poll.** {§join-blocking-collect} Fork-join theory (`ARCHITECTURE.md`): collecting a child blocks until it completes; a non-blocking try-join that returns "not ready, come back" invites a busy-wait, which the cycle detector then strikes (the topo degenerate-win: a parent spin-waited on `SEND[102]` for 8 turns and struck out at 508 while both its workers succeeded). So a path-absent `READ(worker://<running-child>)` returns **425** (Too Early) and the child is now a **live obligation** on the loop; the turn's `SEND[202]` **blocks on the join** (§wait-obligation-matrix, `status 202`) instead of spinning. On the child's terminal (ANY status — the collect-delta pushes success open, abandonment folded) the join **reawakens the same loop** with the result in its log, its prompt intact. The model writes straight-line "spawn, then READ the result"; the engine hides the scheduler (a blocking `read()`), so worker-await needs no park syntax and no teaching — the concurrency lives in the kernel. **Liveness** rides the existing guarantee: children are bounded (their own maxTurns/strikes → a guaranteed terminal in the terminal set), the join wakes on any terminal incl. failure, and the owed-wake covers the finished-before-parked race — so the block cannot deadlock. Any `SEND` clears the per-turn arm; a `SEND[200]` terminal with a live child stays the existing premature-terminate steer (a terminal carries the final answer, so it must collect-then-conclude, never auto-join). The **default is happy and safe** — bare `READ` blocks indefinitely (safe by boundedness); a `<seconds>` timeout-poll (425 as its expiry) is the opt-in expert path.
199
199
 
200
- A run is a **log plus a cancellation scope** — one `AbortController` per run, reused while live and replaced only once aborted, so a cancel ends the run as a unit and a later `loop.run` is never born cancelled. A run's queued loops are advanced by a **drain**: a single per-run worker that claims loops atomically (status 100→102) and runs each under the run's scope. A loop may spawn **streams** (execs) that outlive it; each is a row in the subscription registry (§subscriptions) — the durable record of what the run holds open. Cancellation and conclusion are defined against these structures, never wall-clock timing.
200
+ A worker is a **log plus a cancellation scope** — one `AbortController` per worker, reused while live and replaced only once aborted, so a cancel ends the worker as a unit and a later `loop.run` is never born cancelled. A worker's queued loops are advanced by a **drain**: a single per-worker worker that claims loops atomically (status 100→102) and runs each under the worker's scope. A loop may spawn **streams** (execs) that outlive it; each is a row in the subscription registry (§subscriptions) — the durable record of what the worker holds open. Cancellation and conclusion are defined against these structures, never wall-clock timing.
201
201
 
202
- - **One drain advances a run.** At most one drain is registered for a run at any instant: a `loop.run` or wake on a run with a live drain folds in (active→next-turn) or enqueues a loop that drain claims, never a second parallel drain. A drain's start and its empty-queue teardown relinquish run under one per-run lock, so the teardown's re-claim cannot race a concurrent start into a double-drain. {§run-lifecycle-single-drain}
203
- - **A cancel reaps every stream the run holds — by the registry.** `loop.cancel` / `KILL` / shutdown abort the run scope AND iterate the run's open subscriptions, aborting each via its owning scheme; the registry is the source of truth, the in-process abort signal a fast-path optimization. A stream that is running, mid-spawn (its row written before it is killable), or spawned after the cancel is reaped alike. The teardown abort is a BOUNDED reap — the executor sends a polite signal then SIGKILL after a consumer-set grace (`PLURNK_SERVICE_EXEC_KILL_GRACE_MS`), so a signal-ignoring stream can't wedge it; a model `KILL[code]` on a live stream instead delivers exactly that signal once (bare `KILL` → the executor's SIGHUP default, `KILL[9]` → SIGKILL), the model owning any escalation. {§run-lifecycle-total-reap}
204
- - **A stream's kill binds to the scope it captured at spawn.** A stream captures the run's cancellation scope as it registers and wires its kill to it, re-checking `aborted` AFTER wiring — no check-then-listen gap can drop an abort that lands mid-registration. Because the scope is replaced only once aborted, a captured-then-replaced scope is necessarily already aborted, so replacement never strands a live stream. {§run-lifecycle-exec-epoch-bound}
205
- - **A cancelled run is not resurrected by its own torn-down work.** A stream conclusion delivered to a cancelled, idle run starts no fresh drain: an aborted (499) conclusion is skipped, and a straggler that concluded cleanly surfaces its deliverable as an environment delta (§env-delta), never a revived loop. The cancel was deliberate; only an explicit `loop.run` resumes the run. {§run-lifecycle-no-resurrection}
206
- - **A stream conclusion always reaches its run.** When a backgrounded stream concludes, the daemon routes it through the same inject seam as any loop source (§actor-boundary-passive-wake): an active run folds the conclusion into its next turn; a run **blocked on a 202 wait** for that stream (§wait-obligation-matrix) **awakens that loop in place** — the blocked loop *is* the continuation, so there is no fresh loop and no summary-as-prompt fiction. The result is never lost: a blocked loop sleeps rather than ending, and the stream's status-transition is the arrival (§actor-boundary-passive-wake) that wakes it; on resume it reads the concluded stream's own state, not a synthetic prompt. {§run-lifecycle-wake-liveness}
207
- - **A child run concluding wakes a parent blocked on it — the topology join.** `run://` spawn/fork records `parent_run_id` (§lifecycle-terms). When a run's drain exits having **concluded** — no `202`-blocked loop, no open stream — the daemon resumes its parent **in place** if the parent is blocked on the join (`#onDrainExit` → the shared `#wakeParkedRun`, the same 202→100 resume a stream conclusion uses). So a parent that spawns work and blocks (`SEND[202]`) is woken the moment its child finishes; on resume it reads the child's deliverable from the §run-scheme-collect delta in its own log — a control edge, **never an injected prompt**. The wake recurses upward via the parent's own drain-exit. A child still running — or itself blocked at 202 — is not *concluded*, so it does not wake the parent (it's still a live thing the subtree holds). This is the structured-concurrency join: streams and child runs are the same kind of "live thing a run holds," driving premature-terminate (§send-premature-terminate), the wake edge, and the collect delta identically. A worker-run conclusion is a **bounded, un-loseable** wake: if the conclusion fires while the parent is mid-turn (before its block commits), `#wakeParkedRun` finds it not-yet-slept and records an **owed wake**, which the drain honors when the parent blocks — so a wait awaiting worker runs **always returns**, never dead-blocks on a conclude-before-block race. (Only a live exec stream, unbounded absent a timeout, may legitimately hold a wait open.) {§run-lifecycle-child-wake}
208
- - **An idle run concludes; it does not park.** A loop with nothing in flight — no live child, no open stream — that signals a wait (`SEND[202]`) or simply runs out of work **concludes** (`200`): a wait on zero obligations is satisfied (§wait-obligation-matrix), so there is no held-open `202` loop and no `loop/quiesced` soft signal. A concluded run is *already* reawakable — it is durable working history (§machine-processes-run-is-its-log, engine_render_log spans the whole run), and an addressed arrival (a sibling `irc`, an operator inject, a later `loop.run`) reawakens it as a **new loop** carrying that arrival's real prompt. Only a loop that holds a live obligation *blocks* (§wait-obligation-matrix, the `202+J` join); a subtree with any live thing under it is not idle, and that thing's conclusion reawakens the **same** loop with its prompt intact (§run-lifecycle-child-wake). {§run-lifecycle-idle-is-concluded}
209
- - **A loop is never stranded by a drain's exit.** A drain relinquishes its registry slot only after a lock-held re-claim confirms the queue is empty; a loop enqueued during that teardown is either re-claimed by the exiting drain or claimed by a fresh drain that a later inject starts. The relinquish and the start are serialized, so neither the lost-loop hang nor a transient double-drain can occur. {§run-lifecycle-no-lost-loop}
202
+ - **One drain advances a worker.** At most one drain is registered for a worker at any instant: a `loop.run` or wake on a worker with a live drain folds in (active→next-turn) or enqueues a loop that drain claims, never a second parallel drain. A drain's start and its empty-queue teardown relinquish the worker under one per-worker lock, so the teardown's re-claim cannot race a concurrent start into a double-drain. {§worker-lifecycle-single-drain}
203
+ - **A cancel reaps every stream the worker holds — by the registry.** `loop.cancel` / `KILL` / shutdown abort the worker scope AND iterate the worker's open subscriptions, aborting each via its owning scheme; the registry is the source of truth, the in-process abort signal a fast-path optimization. A stream that is running, mid-spawn (its row written before it is killable), or spawned after the cancel is reaped alike. The teardown abort is a BOUNDED reap — the executor sends a polite signal then SIGKILL after a consumer-set grace (`PLURNK_SERVICE_EXEC_KILL_GRACE_MS`), so a signal-ignoring stream can't wedge it; a model `KILL[code]` on a live stream instead delivers exactly that signal once (bare `KILL` → the executor's SIGHUP default, `KILL[9]` → SIGKILL), the model owning any escalation. {§worker-lifecycle-total-reap}
204
+ - **A stream's kill binds to the scope it captured at spawn.** A stream captures the worker's cancellation scope as it registers and wires its kill to it, re-checking `aborted` AFTER wiring — no check-then-listen gap can drop an abort that lands mid-registration. Because the scope is replaced only once aborted, a captured-then-replaced scope is necessarily already aborted, so replacement never strands a live stream. {§worker-lifecycle-exec-epoch-bound}
205
+ - **A cancelled run is not resurrected by its own torn-down work.** A stream conclusion delivered to a cancelled, idle worker starts no fresh drain: an aborted (499) conclusion is skipped, and a straggler that concluded cleanly surfaces its deliverable as an environment delta (§env-delta), never a revived loop. The cancel was deliberate; only an explicit `loop.run` resumes the worker. {§worker-lifecycle-no-resurrection}
206
+ - **A stream conclusion always reaches its worker.** When a backgrounded stream concludes, the daemon routes it through the same inject seam as any loop source (§actor-boundary-passive-wake): an active worker folds the conclusion into its next turn; a worker **blocked on a 202 wait** for that stream (§wait-obligation-matrix) **awakens that loop in place** — the blocked loop *is* the continuation, so there is no fresh loop and no summary-as-prompt fiction. The result is never lost: a blocked loop sleeps rather than ending, and the stream's status-transition is the arrival (§actor-boundary-passive-wake) that wakes it; on resume it reads the concluded stream's own state, not a synthetic prompt. {§worker-lifecycle-wake-liveness}
207
+ - **A child worker concluding wakes a parent blocked on it — the topology join.** `worker://` spawn/fork records `parent_worker_id` (§lifecycle-terms). When a worker's drain exits having **concluded** — no `202`-blocked loop, no open stream — the daemon resumes its parent **in place** if the parent is blocked on the join (`#onDrainExit` → the shared `#wakeParkedWorker`, the same 202→100 resume a stream conclusion uses). So a parent that spawns work and blocks (`SEND[202]`) is woken the moment its child finishes; on resume it reads the child's deliverable from the §worker-scheme-collect delta in its own log — a control edge, **never an injected prompt**. The wake recurses upward via the parent's own drain-exit. A child still running — or itself blocked at 202 — is not *concluded*, so it does not wake the parent (it's still a live thing the subtree holds). This is the structured-concurrency join: streams and child workers are the same kind of "live thing a worker holds," driving premature-terminate (§send-premature-terminate), the wake edge, and the collect delta identically. A worker conclusion is a **bounded, un-loseable** wake: if the conclusion fires while the parent is mid-turn (before its block commits), `#wakeParkedWorker` finds it not-yet-slept and records an **owed wake**, which the drain honors when the parent blocks — so a wait awaiting workers **always returns**, never dead-blocks on a conclude-before-block race. (Only a live exec stream, unbounded absent a timeout, may legitimately hold a wait open.) {§worker-lifecycle-child-wake}
208
+ - **An idle run concludes; it does not park.** A loop with nothing in flight — no live child, no open stream — concludes by SAYING so (`SEND[200]`); a `SEND[202]` there is the ∅-wait contradiction and returns 409 (§wait-obligation-matrix), so there is no held-open `202` loop, no `loop/quiesced` soft signal, and no silent engine-conclusion over a confusion. A concluded worker is *already* reawakable — it is durable working history (§machine-processes-worker-is-its-log, engine_render_log spans the whole worker), and an addressed arrival (a sibling `irc`, an operator inject, a later `loop.run`) reawakens it as a **new loop** carrying that arrival's real prompt. Only a loop that holds a live obligation *blocks* (§wait-obligation-matrix, the `202+J` join); a subtree with any live thing under it is not idle, and that thing's conclusion reawakens the **same** loop with its prompt intact (§worker-lifecycle-child-wake). {§worker-lifecycle-idle-is-concluded}
209
+ - **A loop is never stranded by a drain's exit.** A drain relinquishes its registry slot only after a lock-held re-claim confirms the queue is empty; a loop enqueued during that teardown is either re-claimed by the exiting drain or claimed by a fresh drain that a later inject starts. The relinquish and the start are serialized, so neither the lost-loop hang nor a transient double-drain can occur. {§worker-lifecycle-no-lost-loop}
210
210
 
211
211
  ---
212
212
 
@@ -220,7 +220,7 @@ Three entry points:
220
220
 
221
221
  - `provider.generate({messages, signal})` — once per turn; returns `{ assistant: { content, reasoning, usage, finishReason, model }, assistantRaw, meta? }`. **Engine parses `assistant.content`** into `PlurnkStatement[]` via `@plurnk/plurnk-grammar`. {§provider-surface-generate}
222
222
  - `provider.countTokens(text)` — synchronous, called at write-time (§tokenomics) and render-time. Non-negative integer. {§provider-surface-counttokens}
223
- - `provider.costFor(usage)` — once per completed turn; pico-USD. Engine writes to `turns.usage_cost_pico`; triggers cascade to `runs.cost_pico` / `sessions.cost_pico`. {§provider-surface-costfor}
223
+ - `provider.costFor(usage)` — once per completed turn; pico-USD. Engine writes to `turns.usage_cost_pico`; triggers cascade to `workers.cost_pico` / `workspaces.cost_pico`. {§provider-surface-costfor}
224
224
 
225
225
  Plus immutable identity: `provider.contextSize` (token total, or `null` → "no budget info"), read by the budget {§provider-surface-identity}; and `provider.model` — the instance identity the deferred model-switch recompute compares (§tokenomics), exposed but not yet consumed here.
226
226
 
@@ -229,7 +229,7 @@ Plus immutable identity: `provider.contextSize` (token total, or `null` → "no
229
229
  ### §provider-guarantees Engine → provider guarantees
230
230
 
231
231
  - `messages` is a complete prompt (the section list, pre-assembled into the system + user messages). Provider does not reorder.
232
- - `signal` is wired to the run's AbortController. {§provider-guarantees-signal-wired}
232
+ - `signal` is wired to the worker's AbortController. {§provider-guarantees-signal-wired}
233
233
  - `generate` is single-call per turn. No parallel calls on the same instance. {§provider-guarantees-single-call}
234
234
  - `assistantRaw` is opaque to the engine (forensics-only). {§provider-guarantees-assistantraw-opaque}
235
235
  - `countTokens` is cheap by contract; engine calls frequently.
@@ -248,7 +248,7 @@ The loop's **current strike streak** rides `generate({ strikes })` the same way
248
248
 
249
249
  **The `@plurnk/` namespace is reserved.** A package may declare an `@plurnk/` tag only if it is itself `@plurnk/`-scoped (npm enforces scope ownership at publish); otherwise it fails hard. {§attribution-plurnk-namespace-reserved}
250
250
 
251
- **The session's `client` id rides the same wire.** A frontend self-identifies (e.g. `plurnk.nvim/1.4.0`) at `session.create({ settings: { client } })`; the engine forwards it per turn on `generate({ client })`, which only the `plurnk` provider emits (as `Plurnk-Client`). Session-stable and self-reported — distinct from attribution's install-grounded tags — and omitted when unset. {§client-telemetry}
251
+ **The workspace's `client` id rides the same wire.** A frontend self-identifies (e.g. `plurnk.nvim/1.4.0`) at `workspace.create({ settings: { client } })`; the engine forwards it per turn on `generate({ client })`, which only the `plurnk` provider emits (as `Plurnk-Client`). Workspace-stable and self-reported — distinct from attribution's install-grounded tags — and omitted when unset. {§client-telemetry}
252
252
 
253
253
  Deferred (#249): grounding the attribution value in real per-turn value flow rather than the active-plugin placeholder, token-weighting, and entry-level attribution. Native surfacing of the field in each framework's `discover()` supersedes the service-side manifest read and extends collection to mimetype + provider plugins.
254
254
 
@@ -298,7 +298,7 @@ Per author contract (`readEntry` / `writeEntry` / `deleteEntry`). Engine drives
298
298
 
299
299
  Per author contract (`edit`/`read`/`open`/`fold`/`find`/`send`/`exec?`). Engine dispatches by `PlurnkStatement.op`. {§op-methods-op-dispatch} COPY and MOVE are NOT scheme methods — engine orchestrates over CRUD primitives (§copy/§move).
300
300
 
301
- - **Every op completes before the next begins — except the three concurrency-creators.** {§op-synchronous} Within a turn ops run in sequence, and each is *decisive*: its effect has fully landed by the time the next op — or the terminal gate — reads it. The dispatch `await`s each, so `KILL`, `EDIT`, `COPY`, `MOVE`, `READ` (including the blocking worker-collect, §run-scheme-collect), `FIND`, `OPEN`, `FOLD` never leave work "in flight" for a later op to trip over. The ONLY exceptions are the ops whose *job* is to create concurrency and hand it back tracked: **FORK** and **WORK** (spawn a child run) and **stream-producers** (`EXEC` and friends) return the instant the concurrent thing is launched and registered (the §child-orientation sections), never blocking the turn on its conclusion. This is the OS syscall model exactly — calls block; only `fork`/`spawn`/async-I/O don't — and it is why a same-turn `KILL + SEND[200]` concludes (§send-premature-terminate): `KILL` synchronously flips the run's live loops terminal (`engine_terminate_run_live_loops`) so the gate sees it dead at once, while the physical scope reap rides `cancelRun` asynchronously and invisibly. The obligation is the model's protection: it must never reason about whether its last decisive op has "landed yet."
301
+ - **Every op completes before the next begins — except the three concurrency-creators.** {§op-synchronous} Within a turn ops run in sequence, and each is *decisive*: its effect has fully landed by the time the next op — or the terminal gate — reads it. The dispatch `await`s each, so `KILL`, `EDIT`, `COPY`, `MOVE`, `READ` (including the blocking worker-collect, §worker-scheme-collect), `FIND`, `OPEN`, `FOLD` never leave work "in flight" for a later op to trip over. The ONLY exceptions are the ops whose *job* is to create concurrency and hand it back tracked: **FORK** and **WORK** (spawn a child worker) and **stream-producers** (`EXEC` and friends) return the instant the concurrent thing is launched and registered (the §child-orientation sections), never blocking the turn on its conclusion. This is the OS syscall model exactly — calls block; only `fork`/`spawn`/async-I/O don't — and it is why a same-turn `KILL + SEND[200]` concludes (§send-premature-terminate): `KILL` synchronously flips the worker's live loops terminal (`engine_terminate_worker_live_loops`) so the gate sees it dead at once, while the physical scope reap rides `cancelWorker` asynchronously and invisibly. The obligation is the model's protection: it must never reason about whether its last decisive op has "landed yet."
302
302
 
303
303
  ### §orchestration Cross-scheme orchestration
304
304
 
@@ -327,7 +327,7 @@ Directed SEND (non-null path) routes to scheme's `send`. Status = intent:
327
327
  - `SEND[200](path)` — write body into resource (WS message, exec stdin).
328
328
  - `SEND[499](path)` — cancel active subscription (§stream).
329
329
 
330
- - **Log speaks the universal query contract** {§log-uniform-query} — `FIND(log://…)` works like every scheme's FIND (the jumbo model ran the canon-documented `FIND(log:///**):#…#` six times into a bare 501 — log was the one scheme outside the universal paradigm, by accident not decision). Candidates are the run's rows scoped by the coordinate hierarchy ({§log-coordinate-hierarchy}); each is projected EXACTLY as READ shows it (the rx unwrap — FIND must match what the model can retrieve); the shared `Matcher.matchCandidates` ({§find-source-agnostic}) applies any content dialect; results are catalog-shaped items keyed `log:///loop/turn/seq/OP` with `{mimetype, tokens, lines}`, and the fan-out retargets per-row READs so `READ(log://…):matcher` composes identically to entries (#286). Log rows carry no tags (engine-written events a tag filter is 204-empty) and no symbol channels (`@graph` 501, an honest absence). `~semantic` is an HONEST 501 until the pump embeds log rows under the same uniform rules as entries (no selection heuristics — protocol-ack dust self-filters at the FTS-narrowing stage); storage stays the event stream (the Q3/Option-B decision: uniform QUERY contract, honest distinct storage).
330
+ - **Log speaks the universal query contract** {§log-uniform-query} — `FIND(log://…)` works like every scheme's FIND (the jumbo model ran the canon-documented `FIND(log:///**):#…#` six times into a bare 501 — log was the one scheme outside the universal paradigm, by accident not decision). Candidates are the worker's rows scoped by the coordinate hierarchy ({§log-coordinate-hierarchy}); each is projected EXACTLY as READ shows it (the rx unwrap — FIND must match what the model can retrieve); the shared `Matcher.matchCandidates` ({§find-source-agnostic}) applies any content dialect; results are catalog-shaped items keyed `log:///loop/turn/seq/OP` with `{mimetype, tokens, lines}`, and the fan-out retargets per-row READs so `READ(log://…):matcher` composes identically to entries (#286). A tag signal filters candidates by the model's own region tags ({§log-region-tagging}); log rows carry no symbol channels (`@graph` 501, an honest absence). `~semantic` is an HONEST 501 until the pump embeds log rows under the same uniform rules as entries (no selection heuristics — protocol-ack dust self-filters at the FTS-narrowing stage); storage stays the event stream (the Q3/Option-B decision: uniform QUERY contract, honest distinct storage).
331
331
  - **The content matcher is source-agnostic** {§find-source-agnostic} — `Matcher.matchCandidates(body, candidates, mimetypes)` applies a content matcher (regex/jsonpath/xpath/glob) to candidates from ANY source, keyed by the caller's own identity (a pathname for entries, a `loop/turn/seq` coordinate for log). The matcher never cares what table the content came from, so FIND/READ with every content dialect works uniformly across schemes BY CONSTRUCTION — `EntryFind` and `Log.find` run the ONE shared primitive rather than re-implementing per scheme. This is the query-layer half of the log-uniformity decision (Q3, Option B): log stays its own event stream, but its rows are candidates the shared matcher covers like any entry's content.
332
332
  - **Line-oriented matching is the tent pole; the selection signal is additive** {§matcher-selection-signal} — a pattern match returns the SOURCE LINE containing it, with its line number (`42:\tI bought Alice some flowers`, never `1:\tAlice`): the line is the universal coordinate the whole op-algebra composes on (FIND spans → READ delivers → EDIT mutates), and extraction dialects admit NO exception (owner ruling — a bare value has no coordinate and silently breaks the composition). The degenerate case — a MINIFIED single-line document, where "the line" is the whole file — is answered with SIGNAL on the FIND row, not a semantics change: a matcher READ's fan-out WRITES its internal FIND as a selection-summary row (sequence-first, exactly as if the model had FINDed then READ), whose rx carries one result item per HIT with `matchSpan` and `matchPath` (the hit's canonical dialect coordinate, e.g. `$['users'][0]['name']`, when the dialect provides one). Deliveries stay span-DEDUPED (#286 — N hits on one source line deliver that line once, no identical-row noise); the summary row is what tells the model its query hit N times and WHERE (run30: two hits indistinguishable from failure; 17 retries, 508). The teaching half is grammar's canon (grammar#56).
333
333
 
@@ -344,15 +344,15 @@ Per-call context (`src/core/scheme-types.ts`):
344
344
  ```ts
345
345
  interface PlurnkSchemeContext {
346
346
  readonly db: Db;
347
- readonly sessionId: number;
348
- readonly runId: number;
347
+ readonly workspaceId: number;
348
+ readonly workerId: number;
349
349
  readonly loopId: number;
350
350
  readonly turnId: number;
351
351
  readonly writer: "model" | "client" | "plurnk" | "plugin"; // WriterTier
352
352
  readonly signal: AbortSignal | undefined;
353
353
  readonly streamEventNotify?: StreamEventNotify;
354
- readonly wakeRunNotify?: WakeRunNotify;
355
- readonly injectRun?: InjectRunNotify; // run:// spawn/fork/irc loop-start (§run-scheme)
354
+ readonly wakeWorkerNotify?: WakeWorkerNotify;
355
+ readonly injectWorker?: InjectWorkerNotify; // worker:// spawn/fork/irc loop-start (§worker-scheme)
356
356
  readonly mimetypes?: Mimetypes;
357
357
  readonly executors?: ExecutorRegistry; // boot-discovered EXEC runtimes (§exec)
358
358
  readonly tokenize?: (text: string) => number; // write-time tokenizer (§tokenomics)
@@ -361,14 +361,14 @@ interface PlurnkSchemeContext {
361
361
  }
362
362
  ```
363
363
 
364
- The optional engine-/daemon-populated capabilities (the notifiers, `injectRun`, `executors`, `tokenize`, `defaultChannelFor`, `pushTelemetry`) are absent in bare test fixtures; a handler that needs one **fail-hards** rather than silently degrading (no default runtime, no silent zero-token write).
364
+ The optional engine-/daemon-populated capabilities (the notifiers, `injectWorker`, `executors`, `tokenize`, `defaultChannelFor`, `pushTelemetry`) are absent in bare test fixtures; a handler that needs one **fail-hards** rather than silently degrading (no default runtime, no silent zero-token write).
365
365
 
366
366
  Engine → scheme guarantees:
367
367
 
368
368
  - `ctx` is fresh per call. No mutation across calls.
369
369
  - `ctx.writer` reflects the actual writer at this dispatch.
370
370
  - `manifest.writableBy` checked BEFORE invocation; engine returns 403 directly on exclusion. {§scheme-surface-writableby-403}
371
- - `ctx.signal` is wired to the run's AbortController (§provider-guarantees-signal-wired).
371
+ - `ctx.signal` is wired to the worker's AbortController (§provider-guarantees-signal-wired).
372
372
  - Scheme exceptions become the action-entry's outcome (status 500); summary surfaces in next turn's `errors` section (§telemetry). {§scheme-surface-exception-500}
373
373
 
374
374
  **Tokenization participation.** Schemes route writes through the shared `_entry-crud.ts` write helper (in plurnk-service today; migrates to plurnk-schemes). Helper populates `entry_channels.tokens` at write time via `ctx.tokenize` (§tokenomics-tokens-stored-at-write). Raw DB writes bypass tokenization — out of API scope.
@@ -379,7 +379,7 @@ Engine → scheme guarantees:
379
379
 
380
380
  Author-facing contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). Below: firing semantics + consumption surface.
381
381
 
382
- **Firing semantics.** Render-time consumers. Engine invokes during packet assembly; handlers read current channel content (possibly mid-stream), produce structural view, result lands in the manifest catalog. Schemes do NOT call mimetype handlers at write — writes are verbatim. Two content lifecycles sit outside that rule and are not exceptions to it: (1) **write-time indexing** — the FTS keyword half derives at write (handler-free, plain string→FTS) so a cold session's first query narrows; the vector half and every structural VIEW (symbols/@graph, tokens, lines, extent) derive at RENDER, re-computed each packet so a model/tokenizer change can never leave a stored snapshot lying. (2) **fetch-time materialization** — the web-fetch tool (the exec sink) projects a fetched `text/html` page through the handler's readable projection ONCE and stores THAT as the decisive `body` (`text/markdown` — what READ serves, FIND matches, and every price reports), archiving the raw page under `html` (xpath + fidelity). This sets the body's identity, not a re-derived view; an AUTHORED/workspace html file is written verbatim (its attributes are data). {§mimetype-schemes-do-not-invoke-handlers}
382
+ **Firing semantics.** Render-time consumers. Engine invokes during packet assembly; handlers read current channel content (possibly mid-stream), produce structural view, result lands in the manifest catalog. Schemes do NOT call mimetype handlers at write — writes are verbatim. Two content lifecycles sit outside that rule and are not exceptions to it: (1) **write-time indexing** — the FTS keyword half derives at write (handler-free, plain string→FTS) so a cold workspace's first query narrows; the vector half and every structural VIEW (symbols/@graph, tokens, lines, extent) derive at RENDER, re-computed each packet so a model/tokenizer change can never leave a stored snapshot lying. (2) **fetch-time materialization** — the web-fetch tool (the exec sink) projects a fetched `text/html` page through the handler's readable projection ONCE and stores THAT as the decisive `body` (`text/markdown` — what READ serves, FIND matches, and every price reports), archiving the raw page under `html` (xpath + fidelity). This sets the body's identity, not a re-derived view; an AUTHORED/workspace html file is written verbatim (its attributes are data). {§mimetype-schemes-do-not-invoke-handlers}
383
383
 
384
384
  ### §mimetype-manifest Manifest
385
385
 
@@ -440,7 +440,7 @@ Fallback heuristic is a boot-before-provider-resolved tripwire.
440
440
  ```ts
441
441
  const result = await mimetypes.process({ content: r.content, hint: r.mimetype }, { channels: ["symbols", "references"] });
442
442
  entry.channels[r.channel] = { mimetype: r.mimetype, tokens: tokenize(r.content), lines: result.totalLines };
443
- if (isBody) await EntryGraph.populateFrom(db, sessionId, r.entry_id, result.symbols ?? [], result.references ?? []);
443
+ if (isBody) await EntryGraph.populateFrom(db, workspaceId, r.entry_id, result.symbols ?? [], result.references ?? []);
444
444
  ```
445
445
 
446
446
  `hint` short-circuits detection. The service consumes `totalLines` (extent), `symbols`/`references` (the `@graph` index), and `deepJson`/`deepXml` (matcher dispatch); never a rendered preview — content reaches the model on READ. Because this pass runs every assembly over every entry, any content change — by any writer — is reflected in the next packet's index. The `@graph` index is NOT engine *ranking* (the anti-pattern): it's a complete, unranked index the model queries via `FIND @<sym`, the manifest paradigm applied to structure, uniform across schemes (`file:///` is the primary case).
@@ -465,7 +465,7 @@ Schemes MAY declare multiple channels (`exec`: stdout/stderr/stdin; `http`: body
465
465
 
466
466
  ### §no-visibility Entries carry no visibility
467
467
 
468
- Every entry is uniformly listed in the catalog (`FIND(scheme:///**)`, §packet) and READable — entries have no per-run open/folded state. Context curation is the model's, on the **log** (via OPEN/FOLD, §open-fold), never on entries.
468
+ Every entry is uniformly listed in the catalog (`FIND(scheme:///**)`, §packet) and READable — entries have no per-worker open/folded state. Context curation is the model's, on the **log** (via OPEN/FOLD, §open-fold), never on entries.
469
469
 
470
470
  ### §channel-mimetype Mimetype is a (scheme, channel) property — never a default
471
471
 
@@ -553,14 +553,21 @@ AST: `{ op: "OPEN"|"FOLD", target, body: MatcherBody | null, signal: tags | null
553
553
 
554
554
  OPEN/FOLD operate on the **log** (`log:///`) — the model's context-curation surface (§packet). FOLD collapses a log row to its path; OPEN restores its body. Non-destructive: rows and bodies persist, re-OPENable. Entries carry no visibility (§no-visibility), so OPEN/FOLD against an entry scheme returns 501.
555
555
 
556
+ ### §jsonplurnk The Log's wire format
557
+
558
+ The `## Log` section renders as a fenced `jsonplurnk` block — a JSON array of entry objects, otherwise-valid JSON with **exactly one** deviation: each `body` value, when present, is a raw HEREDOC (`<<:::TAG … :::TAG`, TAG = the entry's target/log URI), rendered verbatim (numbered for text, tree-navigable verbatim), never a JSON-escaped string. The carve-out is localized to `body`, so the strip-parser is trivial — after `"body":`, `<<:::TAG` opens and `:::TAG` at column 0 closes; replacing that block with an escaped string recovers strict JSON (the plurnkdown linter's transform). Each entry's fold state is a self-documenting `display` field (grammar-ratified #437 — `display:none` mirrors CSS, `folded`/`open` echo the FOLD/OPEN ops): `"display":"none"` (no body), `"folded"` (a body exists but is hidden, still weighable), `"open"` (body shown, heredoc present). The block's leading Note defines the carve-out AND the token semantics in-packet (#466 — a number the model can see states its meaning where it appears, never in a doc it can't): `tokens` prices the row's body in this packet (what OPEN adds, what FOLD saves); a FIND's `itemsTokenTotal` sizes the matched entries themselves (their READ cost). The invariants bind regardless of shape (§packet): addressability (`path`/`target`/`#channel`/numbered bodies), weighability (per-item `tokens`), honesty (every 4xx/5xx row, bodyless-0-token items, the explicit fold state). This is the Log's realization of the packet-wide plurnkdown house style — the whole outbound packet is one coherent document, log data stays JSON housed in a fence. {§jsonplurnk}
559
+
560
+ The opening fence length is **dynamic**: one backtick longer than the longest backtick run in any body (floor 3). A body can carry arbitrary content — a READ of a doc whose own text opens a column-0 triple-backtick fence — which a fixed opener would let close the block early; a dynamic opener can never be closed by its own body content (CommonMark closes a fence only on a line of at least its own length), independent of the `N:\t` numbering that incidentally keeps text bodies off column 0. {§jsonplurnk-dynamic-fence}
561
+
556
562
  ### §model-entry The model's own emission, mirrored back
557
563
 
558
564
  A `model` log row is the model's **verbatim prior emission**, mirrored back so it can finally SEE its own behavior — and reason through its own syntax errors (the parser reports by line; the row renders line-numbered like all content). Actionless, like an `op='error'` row (§telemetry): no target, no op executed; `tx` is empty and the emission lives in `rx.content`, typed `text/vnd.plurnk`. **Always born FOLDED** (budget-neutral) — the retired born-OPEN-on-error auto-trigger was conditional helpfulness that bred its own hazards (a giant erred emission mirrored open re-injects itself into the next packet: cost, contamination, pressure feedback). An error's reported `line:col` resolves the way everything else does: the model that cares `READ`s the folded row at the lines it wants — and can introspect ANY prior emission of its own the same way. OPEN/FOLD/KILL-able like any log row — the model curates its own history, and log-KILL clears the `writableBy` gate for the model (the DB-storage curation lever plurnk.md teaches; Log's handler surface — kill only — keeps every other mutating op at 501). {§model-entry-log-curation} The engine writes one at the end of each turn that produced output; a struck/empty turn mirrors nothing.
559
565
 
560
566
  - **Log coordinates are a hierarchical prefix; the trailing slash is optional** {§log-coordinate-hierarchy} — a coordinate is `loop/turn/sequence`, and a PARTIAL coordinate selects its descendants: `log:///1` = loop 1's rows, `log:///1/2` = turn 1/2's rows, `log:///1/2/3` = the one row. A full coordinate is always three parts, so a one- or two-part path is unambiguously a prefix — the trailing slash is an optional alias (`log:///1/2` ≡ `log:///1/2/`), uniform with `READ(known:///docs/)`. The jumbo model reached for `FOLD(log:///1/2)` (the natural whole-turn form) and got a 400 that seeded a 14-turn rabbit hole; the hierarchy makes the intuitive form the correct form.
561
567
  - **Log curation speaks the folder idiom; a zero-match sweep is a no-op success** {§log-curation-folder-idiom} — OPEN/FOLD/KILL take a concrete coordinate or a path-glob, and a **trailing slash or a partial coordinate means "the contents"** ({§log-coordinate-hierarchy}) exactly as `READ(known:///docs/)` fans out a folder: `FOLD(log:///1/2)` folds turn 1/2's rows. A **well-formed glob that matches nothing is 204 with `matched: 0`** (owner ruling) — a curation sweep that found nothing to curate is not an error, and 204 keeps it off the errors surface where it read as failure and bred retry rituals; a successful sweep's rx carries `matched: N`, clearly shown. 400 remains for a malformed target only (no coordinate, no glob, no slash).
568
+ - **Named region tagging: FOLD applies, OPEN/FIND filter** {§log-region-tagging} — the log's write-op is **FOLD**, because EDIT can't reach engine-written rows (the OP×resource matrix): `FOLD[tag](region)` folds the region AND stamps the tag on it, additively ({§edit-tags-additive}), via `log_tags` (CASCADE-erased with the row on KILL). The read-ops filter: `OPEN[tag]` and `FIND[tag]` select rows carrying EVERY listed tag ({§find-tag-filter-and-semantics}) — a **targetless** `OPEN[tag]` recalls the whole tagged working-set across the worker, a scoped one filters within its glob; an unknown tag matches nothing (204, no-op success). `[tag]`-applies-on-the-write-op / `[tag]`-filters-on-the-read-ops is the same split entries already run (EDIT vs FIND); FOLD merely stands in for EDIT because the log is not an entry scheme. A fork carries a row's tags with its fold-state ({§machine-processes-fork-copies-the-log}). The model curates named working-sets of its own memory: file-away-under-a-name, recall-by-name.
562
569
 
563
- The run's **first** model row is exceptional: a born-OPEN turn-0 **exemplar** — a minimal worked example (`PLAN` → environment `FIND`s → `SEND[102]`) the model always opens on, so the grammar can stay thin (the example teaches the syntax, not a heavy grammar). {§model-entry}
570
+ The worker's **first** model row is exceptional: a born-OPEN turn-0 **exemplar** — a minimal worked example (`PLAN` → environment `FIND`s → `SEND[102]`) the model always opens on, so the grammar can stay thin (the example teaches the syntax, not a heavy grammar). {§model-entry}
564
571
 
565
572
  **OPEN and FOLD are meta-operations — render directives, not actions.** They change how the world *displays*, never what it *is* (scrolling, not editing). A **successful** OPEN/FOLD **is recorded in the log** but **suppressed from the packet render** (#382): the row exists for forensics — a curation act with NO trace is how a weak model folding its own task frame stayed invisible until a database dig — while the render still costs it nothing, so FOLD stays genuinely free (the original rowless design's concern, met by hide-not-drop). The emission also survives verbatim in the `model` mirror. A **failed** OPEN/FOLD (bad target, bad range) renders normally with its status — errors are signals. The idle-turn gate reads the *emitted statements*, so a pure-curation turn is work, never idleness. {§fold-open-meta-operations}
566
573
 
@@ -594,57 +601,57 @@ AST: `{ op: "FIND", target (scope), body: MatcherBody | null (predicate), signal
594
601
  - Filters entries within scope. The target's GLOB-ness sets it: a **bare** path is the exact entry, a **trailing-slash folder** (incl. the scheme root `/`) or an explicit **glob** expands to a scope (the `*` is folderhood, not a blanket prefix), `#regex#` filters by pathname. Same target contract as READ — bare = the entry, folder/glob = a scope to fan out (#286). {§find-scope-prefix-filter}
595
602
  - `body` matcher operates on entry content (glob/regex/jsonpath/xpath), per grammar plurnk.md §"Body matcher dispatch"; the path-glob lives in the (target), not the body. {§find-glob-filter-on-content}
596
603
  - `signal` is a tag filter; entries match if they have ALL listed tags. {§find-tag-filter-and-semantics}
597
- - Session + scheme scoped — no cross-session/cross-scheme leakage. {§find-scoped-isolation}
598
- - Returns `FindResult { status, content, mimetype, results: MatchItem[], matches, pathnames }`. The matcher sets the unit (#286). A **body-less** FIND is the **catalog**: one item per *entry* — `{ path, seconds?, tags?, channels: { <uri>: { mimetype, tokens, lines } } }` (the addressable path, per-channel `{mimetype, tokens, lines}` keyed by URI — default channel → the bare path, non-default → `path#channel` — plus `tags` and a live `seconds` stream age), the manifest's per-scheme slice. A **matcher** FIND resolves to one item per *match*: the entry's catalog row plus the `matchSpan` `{lineStart, lineEnd}` it hit. **A file with N matches yields N items** — the same row repeated, one span each; there is no `matchLines` array. The unit is uniform across every dialect — glob/regex/jsonpath/xpath select line spans, `~`semantic the ranked chunk's span, `@`graph the matched symbol's span — all `(file, span)`, all real content lines (the old "the extent of ~semantic/@graph is not a content line" carve-out was false: a chunk span and a symbol span are line ranges). Order is match order (rank for `~`semantic, source order otherwise); a miss contributes nothing; identical spans dedup. `content` is the items as a JSON array (`application/json`). {§find-result-catalog-rows}
604
+ - Workspace + scheme scoped — no cross-workspace/cross-scheme leakage. {§find-scoped-isolation}
605
+ - Returns `FindResult { status, content, mimetype, results: MatchItem[], matches, pathnames }`. The matcher sets the unit (#286). A **body-less** FIND is the **catalog**: one item per *entry* — `{ path, seconds?, tags?, channels: { <uri>: { mimetype, tokens, lines } } }` (the addressable path, per-channel `{mimetype, tokens, lines}` keyed by URI — default channel → the bare path, non-default → `path#channel` — plus `tags` and a live `seconds` stream age), the manifest's per-scheme slice. A **matcher** FIND resolves to one item per *match*: the entry's catalog row plus the `matchSpan` `{lineStart, lineEnd}` it hit. **A file with N matches yields N items** — the same row repeated, one span each; there is no `matchLines` array. The unit is uniform across every dialect — glob/regex/jsonpath/xpath select line spans, `~`semantic the ranked chunk's span, `@`graph the matched symbol's span — all `(file, span)`, all real content lines (the old "the extent of ~semantic/@graph is not a content line" carve-out was false: a chunk span and a symbol span are line ranges). Order is match order (rank for `~`semantic, source order otherwise); a miss contributes nothing; identical spans dedup. `content` is the items as a JSON array (`application/json`). {§find-result-catalog-rows} **Over the render budget, FIND returns a count, not contents** (#418, `PLURNK_SERVICE_FIND_MAX_MATCHES`): a repo-scale `FIND(**)` over a 19k-entry workspace can't enumerate — materializing every match overflows the window, and a clean grind must not be a crash-and-recover. When the match set exceeds the budget the result carries `overflow: N` and its `content` states the fact (`"N entries match, exceeding the render budget — not enumerated"`), `text/markdown` not the JSON array; the row's object still reports the full count + `itemsTokenTotal`. INDEPENDENT of window size — even a 256k window should not render a whole repo's catalog into one turn. `0`/unset = no gate (small workspaces enumerate as before). {§find-count-not-contents}
599
606
 
600
607
  ### §send SEND
601
608
 
602
609
  AST: `{ op: "SEND", target: ParsedPath | null, body: SendBody | null, signal: number | null }`.
603
610
 
604
- - **Broadcast** (path null): the loop's disposition verb. `signal` is the model's *claim* about the run's state — see the terminal contract.
605
- - **Directed** (path non-null): routes to `scheme.send` per §send-dispatch — stream control / cross-run irc, never a loop terminal.
611
+ - **Broadcast** (path null): the loop's disposition verb. `signal` is the model's *claim* about the worker's state — see the terminal contract.
612
+ - **Directed** (path non-null): routes to `scheme.send` per §send-dispatch — stream control / cross-worker irc, never a loop terminal.
606
613
 
607
- **Terminal contract — the model's surface is `waitpid` in three signals.** A broadcast SEND's status is a claim the engine **verifies against the run's actual state**, never a verdict it trusts. The model signals one intention — **continue (102)**, **done (200)**, or **wait (202)** — plus **499**, give up; the behavior falls out of that intention **crossed with the loop's obligations**, exactly as `waitpid(&status, options)` resolves against a process's live children, and a structured-concurrency scope refuses to close over them. An **obligation** is live work the loop set going and has not consumed: **J**, a child or stream it spawned (the join), and **R**, this turn's own retrievals (READ/FIND/OPEN, whose results land next turn). `<T,P>` is the standard **timeout / poll** override on a wait — a bound and a re-inspection cadence, nothing exotic.
614
+ **Terminal contract — the model's surface is `waitpid` in three signals.** A broadcast SEND's status is a claim the engine **verifies against the worker's actual state**, never a verdict it trusts. The model signals one intention — **continue (102)**, **done (200)**, or **wait (202)** — plus **499**, give up; the behavior falls out of that intention **crossed with the loop's obligations**, exactly as `waitpid(&status, options)` resolves against a process's live children, and a structured-concurrency scope refuses to close over them. An **obligation** is live work the loop set going and has not consumed: **J**, a child or stream it spawned (the join), and **R**, this turn's own retrievals (READ/FIND/OPEN, whose results land next turn). `<T,P>` is the standard **timeout / poll** override on a wait — a bound and a re-inspection cadence, nothing exotic.
608
615
 
609
616
  | intention | ∅ (no obligation) | J (live spawned work) | R (pending retrieval) |
610
617
  |---|---|---|---|
611
618
  | **102** continue | next turn | next turn | next turn |
612
619
  | **200** done | **resolved** — terminal, loop ends | **refused** — Premature-Terminate (KILL to abandon, or wait) | **refused** — forced next turn to see what you fetched |
613
- | **202** wait | **resolves like 200** — a wait on zero things is satisfied; `<-1>+∅` is a bid to hang the agent, folded to done, never honored | **block on the join** — the loop sleeps (`<T>`/`<-1>` bound it, `<P>` polls); its work's conclusion **reawakens the same loop**, prompt intact (§run-lifecycle-child-wake, §run-lifecycle-wake-liveness) | resolves next turn (≈ continue) |
620
+ | **202** wait | **resolves like 200** — a wait on zero things is satisfied; `<-1>+∅` is a bid to hang the agent, folded to done, never honored | **block on the join** — the loop sleeps (`<T>`/`<-1>` bound it, `<P>` polls); its work's conclusion **reawakens the same loop**, prompt intact (§worker-lifecycle-child-wake, §worker-lifecycle-wake-liveness) | resolves next turn (≈ continue) |
614
621
 
615
- **499** gives up regardless of obligations — the model's one self-decided failure (§state-terms), and the only conclude that discards its live subtree. The surface is small on purpose. The **one** non-obvious cell is **200 with an obligation in flight** — a contradiction (you claimed done while you owe work), which the engine holds you to via Premature-Terminate below. Every wait is **bounded** — by a join guaranteed to conclude, or by a finite `<T>`; the lone unbounded-by-anything cell, `<-1>+∅`, self-resolves to done rather than hang. No park-into-the-void, no invented pattern — `waitpid`. {§wait-obligation-matrix}
622
+ **499** gives up regardless of obligations — the model's one self-decided failure (§state-terms), and the only conclude that discards its live subtree. The surface is small on purpose. The **one** non-obvious cell is **200 with an obligation in flight** — a contradiction (you claimed done while you owe work), which the engine holds you to via Premature-Terminate below. Every wait is **bounded** — by a join guaranteed to conclude, or by a finite `<T>`. A wait with NOTHING pending (∅ — no stream, no child, no unretrieved result) is the wait-side contradiction, the mirror of premature-terminate: **409**, the turn returns to the model with the fact (a satisfied wait is a conclusion, and a believed-pending obligation that does not exist is exactly the confusion the error channel must surface — #502 run113 concluded CLEAN on it). No park-into-the-void, no silent conclude-on-nothing — `waitpid` with `ECHILD`. {§wait-obligation-matrix}
616
623
 
617
- **A ∅-collapse is marked as state, never re-worded** (#379, owner ruling). When the collapse concludes a loop, the ENGINE ended the conversation not the model and presenting the loop's park text as a deliverable is the lie: run42's parent COLLECTed a worker's "Standing by for user input" as a status-200 stage result and re-spawned blind, nine times. The terminal record carries `terminated_by='collapse'`; the COLLECT and the loop-termination delta render the marker `[ concluded on ∅-collapse — waited on nothing; no result was produced ]` followed by the model's own last words, unrewritten. Both truth layers, neither fabricated: the engine's act is named, the model's words are preserved. The same field carries `'cancel'` for an externally-cancelled loop (§methods-loop-cancel); `NULL` = the model's own terminal, whose status already carries the story. {§wait-collapse-marked}
624
+ **An engine-authored terminal is marked as state, never re-worded** (#379, owner ruling). `terminated_by` names who ended a loop when the model did not: `'cancel'` for an externally-cancelled loop (§methods-loop-cancel); `NULL` = the model's own terminal, whose status already carries the story. The COLLECT and the loop-termination delta render the named act as a marker before the model's own last words, unrewritten — both truth layers, neither fabricated (run42's parent COLLECTed a worker's "Standing by for user input" as a status-200 stage result and re-spawned blind, nine times). Rows carrying `'collapse'` render the marker `[ concluded on ∅-collapse — waited on nothing; no result was produced ]` the wait itself is a 409 (§wait-obligation-matrix), so no new collapse terminal mints; the render contract holds for every row history carries. {§wait-collapse-marked}
618
625
 
619
626
  The engine's failure terminals — **500** (strike threshold) and **508** (cycle), §engine-rails — are never the model's to pick; they are the engine ruling the loop failed. The surface is small on purpose: the model says done, waiting, or giving up, and is never asked to hold a correct opinion about *how* it failed or *whether* it can be woken — the engine decides those from state.
620
627
 
621
628
  **Three engine error states verify the claim.** None is a status code the model learns; all are engine machinery (§engine-rails), pushed to the model as a steering hint on the next packet and **never** as the strike itself (the model sees errors that happened, never the engine's accounting — the gamification policy, §engine-rails). Each strikes (`turnErrors`) and lets the loop continue so the model can correct; a model that ignores the hint and keeps offending spins out to the engine's 500, seeing only the repeated hint, never the count. (All live at `Engine.runLoop`'s turn close.)
622
629
 
623
630
  - **Idle turn** {§send-idle-turn} — a continuing turn (102) whose ops are only PLAN/SEND — no work op. The model continued with nothing to do. The steer, verbatim: *"If your work is done, conclude with 200. If you're waiting on a child or stream you spawned, SEND[202] to block on it — a 202 with nothing to wait on simply concludes."*
624
- - **Premature terminate — the pending set** {§send-premature-terminate} — `SEND[200]` terminates, gated by ONE rule: *nothing pending may be silently discarded*. pending = open streams/spawns (§subscriptions) ∪ **live child runs** (a child is live by its **latest loop** — the same definition §child-orientation renders, so the gate and the section the model reads never disagree) ∪ **this turn's retrievals** (READ/FIND/OPEN rows, whose results fold back next packet — a [200] over them discards answers the model asked for) ∪ **children terminated during this turn's generation** (`terminated_at` past the current turn's timestamp — no longer *live*, but their §run-scheme collect deltas are queued for the NEXT build and unseen; the 1ms fan-out race where workers outrun the parent's own emission {§send-undelivered-child-term}). The set is judged **at the terminal's own dispatch, post-batch**: the emission's earlier ops have executed, so a same-turn `KILL + [200]` repairs in ONE turn, and a same-turn `WORK + [200]` is caught (the spawn is live by the time the SEND lands). A refusal is **409** with one steer naming the pending kinds — *"KILL what you no longer need; SEND[102] (or [102]<seconds>) to receive the rest; then conclude"* — the row records the attempt faithfully (never rewritten, never erased) and the loop stays a continue. The strike is **kind-specific** (owner ruling): a refusal holding open streams or live children strikes (§grinder-strike-coupling — discarding live work stays serious); a **retrievals-only** refusal teaches WITHOUT striking — atomic-turn-pretrained models pair fetch-and-answer by habit, each refusal is individually correct, and maxTurns bounds the walk; striking executed visibly-converging behavior (the jumbo/admins specimens: three correct refusals → 500 mid-adaptation). Its steer (*"Last turn both performed retrieval operations and attempted to terminate. Retrieval operations force an additional turn to receive results for review and reaction. To conclude, only use PLAN and SEND[200] operations."* — owner wording, #384/run48) carries three lessons the specimen requiems taught: it narrates the refusal as HISTORY (weak models read log rows in the third person and never take "you"-steers as self-addressed — run48's model called our 409 "strange… Anyway" and proceeded), it states the mechanism as law (retrievals FORCE an additional turn — physics, not blame), and it prescribes the concluding emission's legal SHAPE (PLAN + SEND[200] only), leaving no room for the groundedness-trained justify-READ that re-armed the gate four times around a correct answer. **Failures gate the terminal identically** {§send-200-failed-ops} — a turn that carried **failed operations** (the model's own op results at status ≥ 400, or this emission's parse errors) cannot conclude `200`: those errors land in the log **next** packet, so a same-turn `[200]` concludes blind past a failure the model never weighed — refused **409** (*"weigh them, then conclude"*), the row faithful, the loop a continue; next turn, the failures in-log and seen, `[200]` stands (the gate judges only the current turn, so it never re-arms on its own refusal). `SEND[499]` abandons regardless — discard by stated intent, the one legitimate discard; declaring failure IS weighing it. Waiting is its own signal — `SEND[202]<T,P>` (§wait-obligation-matrix): a **bounded** block on the loop's live obligations, `loops.status = 202` internally, woken by any arrival (stream/child conclusion, sibling irc, operator inject) or its `<T>` deadline. A `202` with **no** obligation resolves like `200` — a wait on nothing is already satisfied — and `<-1>+∅` (an unbounded wait on nothing) self-resolves rather than hang the agent. A child that **terminated during this turn's generation** is not ∅: its deliverable is on the doorstep (queued for the next build), so the wait CONTINUES like a retrieval (R semantics — parking would hang, the wake edges having already fired into an unparked run; §send-undelivered-child-term). The groundless park is therefore never *refused*, it is *complete*: an idle run with nothing in flight **concludes** (§run-lifecycle-idle-is-concluded), reawakened later by a new prompt as a new loop. The park deadline is engine-memory consumed by the daemon's drain park-exit (a daemon restart drops pending deadlines; arrivals still wake).
625
- - **SEND[300] is an operator question — a PROPOSAL, the stop-the-world system file edits and MCP auths ride** (owner ruling, #346). Enablement cascades: `PLURNK_QUESTIONS=0` is a servicewide ceiling; otherwise the client affirmatively requests per session (`settings.questions: true` at session.create), which ALSO injects the questions.md teaching — capability and teaching gate as one. Enabled: the `;`-delimited body parses leniently (first segment the question, the rest choices; zero choices = an open question — never malformed), and the ask raises a proposal: dispatch stops the world, `loop/proposal` carries `{question, choices}` in attrs, and the client's `loop.resolve {decision:"accept", body}` delivers the ANSWER — written into the ask's own model-facing rx (`{"status":200,"body":…}`), read next packet. Reject/timeout resolve through the standard §proposal semantics; the turn records a continue either way (never a 300 terminal), and the loop simply proceeds. YOLO never auto-answers a question — it exists precisely to stop the world for a human, and the session opted in. Disabled: refused 409 with a self-decide steer, never a park into the void. {§send-300-choices}
631
+ - **Premature terminate — the pending set** {§send-premature-terminate} — `SEND[200]` terminates, gated by ONE rule: *nothing pending may be silently discarded*. pending = open streams/spawns (§subscriptions) ∪ **live child workers** (a child is live by its **latest loop** — the same definition §child-orientation renders, so the gate and the section the model reads never disagree) ∪ **this turn's retrievals** (READ/FIND/OPEN rows, whose results fold back next packet — a [200] over them discards answers the model asked for) ∪ **children terminated during this turn's generation** (`terminated_at` past the current turn's timestamp — no longer *live*, but their §worker-scheme collect deltas are queued for the NEXT build and unseen; the 1ms fan-out race where workers outrun the parent's own emission {§send-undelivered-child-term}). The set is judged **at the terminal's own dispatch, post-batch**: the emission's earlier ops have executed, so a same-turn `KILL + [200]` repairs in ONE turn, and a same-turn `WORK + [200]` is caught (the spawn is live by the time the SEND lands). A refusal is **409** with one steer naming the pending kinds — *"KILL what you no longer need; SEND[102] (or [102]<seconds>) to receive the rest; then conclude"* — the row records the attempt faithfully (never rewritten, never erased) and the loop stays a continue. The strike is **kind-specific** (owner ruling): a refusal holding open streams or live children strikes (§grinder-strike-coupling — discarding live work stays serious); a **retrievals-only** refusal teaches WITHOUT striking — atomic-turn-pretrained models pair fetch-and-answer by habit, each refusal is individually correct, and maxTurns bounds the walk; striking executed visibly-converging behavior (the jumbo/admins specimens: three correct refusals → 500 mid-adaptation). Its steer (*"Last turn both performed retrieval operations and attempted to terminate. Retrieval operations force an additional turn to receive results for review and reaction. To conclude, only use PLAN and SEND[200] operations."* — owner wording, #384/run48) carries three lessons the specimen requiems taught: it narrates the refusal as HISTORY (weak models read log rows in the third person and never take "you"-steers as self-addressed — run48's model called our 409 "strange… Anyway" and proceeded), it states the mechanism as law (retrievals FORCE an additional turn — physics, not blame), and it prescribes the concluding emission's legal SHAPE (PLAN + SEND[200] only), leaving no room for the groundedness-trained justify-READ that re-armed the gate four times around a correct answer. **Failures gate the terminal identically** {§send-200-failed-ops} — a turn that carried **failed operations** (the model's own op results at status ≥ 400, or this emission's parse errors) cannot conclude `200`: those errors land in the log **next** packet, so a same-turn `[200]` concludes blind past a failure the model never weighed — refused **409** (*"weigh them, then conclude"*), the row faithful, the loop a continue; next turn, the failures in-log and seen, `[200]` stands (the gate judges only the current turn, so it never re-arms on its own refusal). `SEND[499]` abandons regardless — discard by stated intent, the one legitimate discard; declaring failure IS weighing it. Waiting is its own signal — `SEND[202]<T,P>` (§wait-obligation-matrix): a **bounded** block on the loop's live obligations, `loops.status = 202` internally, woken by any arrival (stream/child conclusion, sibling irc, operator inject) or its `<T>` deadline. A `202` with **no** obligation resolves like `200` — a wait on nothing is already satisfied — and `<-1>+∅` (an unbounded wait on nothing) self-resolves rather than hang the agent. A child that **terminated during this turn's generation** is not ∅: its deliverable is on the doorstep (queued for the next build), so the wait CONTINUES like a retrieval (R semantics — parking would hang, the wake edges having already fired into an unparked worker; §send-undelivered-child-term). The groundless park is therefore never *refused*, it is *complete*: an idle worker with nothing in flight **concludes** (§worker-lifecycle-idle-is-concluded), reawakened later by a new prompt as a new loop. The park deadline is engine-memory consumed by the daemon's drain park-exit (a daemon restart drops pending deadlines; arrivals still wake).
632
+ - **SEND[300] is an operator question — a PROPOSAL, the stop-the-world system file edits and MCP auths ride** (owner ruling, #346). Enablement cascades: `PLURNK_QUESTIONS=0` is a servicewide ceiling; otherwise the client affirmatively requests per workspace (`settings.questions: true` at workspace.create), which ALSO injects the questions.md teaching — capability and teaching gate as one. Enabled: the `;`-delimited body parses leniently (first segment the question, the rest choices; zero choices = an open question — never malformed), and the ask raises a proposal: dispatch stops the world, `loop/proposal` carries `{question, choices}` in attrs, and the client's `loop.resolve {decision:"accept", body}` delivers the ANSWER — written into the ask's own model-facing rx (`{"status":200,"body":…}`), read next packet. Reject/timeout resolve through the standard §proposal semantics; the turn records a continue either way (never a 300 terminal), and the loop simply proceeds. YOLO never auto-answers a question — it exists precisely to stop the world for a human, and the workspace opted in. Disabled: refused 409 with a self-decide steer, never a park into the void. {§send-300-choices}
626
633
 
627
634
  ### §exec EXEC
628
635
 
629
- AST: `{ op: "EXEC", target (cwd), body: string | null (command), signal: string | null (runtime tag), lineMarker (timeout/poll) }`.
636
+ AST: `{ op: "EXEC", target (local path, stat-routed to cwd or program), body: string | null (command), signal: string | null (runtime tag), lineMarker (timeout/poll) }`.
630
637
 
631
- Engine routes unconditionally to `exec` scheme (path slot is `cwd`, not a URI). The runtime slot (`signal`) selects an executor, resolved against the boot-time `ExecutorRegistry` — siblings discovered and probed at startup, availability cached, default `sh`. Unknown or unavailable runtime → 501 carrying the probe `detail`. {§exec-registry-resolves}
638
+ Engine routes unconditionally to `exec` scheme (the `(target)` slot is a local path or `file:///` URL, not a member URI). **The target is stat-routed at dispatch** {§exec-target-routing}: a **directory** overrides `cwd` — the body runs there; a **file** is the program/data-source the executor runs, with the body as its stdin, so an **empty body is legal** for a file target (run it, no stdin); a **stat-miss** takes the file arm, letting the runtime report its own not-found rather than a dispatch 400. With no target, `cwd` is the workspace workspace (`project_root`), where the File scheme writes — never the daemon's own cwd. An empty body with a directory target or no target at all is the one 400 (nothing to run). The runtime slot (`signal`) selects an executor, resolved against the boot-time `ExecutorRegistry` — siblings discovered and probed at startup, availability cached, default `sh`. Unknown or unavailable runtime → 501 carrying the probe `detail`. {§exec-registry-resolves}
632
639
 
633
- **Runtime fall-through** {§exec-runtime-fallthrough} — an UNREGISTERED runtime tag falls through to the shell with the tag as the command word: `EXEC[go]:test ./...` runs as sh's `go test ./...` (#350, the execs architect's dispatch spec automating the owner's execs#21 ruling: per-tool runtimes — go, cargo, make, npm — never earn tags; sh is the general answer). The fall-through rides sh's OWN policy gate (a session that disabled sh gets the refusal, never a side door), its output entry lands under `sh://` (it ran on sh, no phantom scheme), and telemetry records `exec_runtime_fallthrough {requested}` — which tags models reach FOR is the data that would ever justify a real runtime, never guesswork. A typo'd tag becomes the shell's own clear 127. No new surface: anything expressible as `EXEC[foo]:bar` was expressible as `EXEC[sh]:foo bar`. The 501 remains for a fall-through-ineligible dispatch (sh itself unregistered or session-disabled).
640
+ **Runtime fall-through** {§exec-runtime-fallthrough} — an UNREGISTERED runtime tag falls through to the shell with the tag as the command word: `EXEC[go]:test ./...` runs as sh's `go test ./...` (#350, the execs architect's dispatch spec automating the owner's execs#21 ruling: per-tool runtimes — go, cargo, make, npm — never earn tags; sh is the general answer). The fall-through rides sh's OWN policy gate (a workspace that disabled sh gets the refusal, never a side door), its output entry lands under `sh://` (it ran on sh, no phantom scheme), and telemetry records `exec_runtime_fallthrough {requested}` — which tags models reach FOR is the data that would ever justify a real runtime, never guesswork. A typo'd tag becomes the shell's own clear 127. No new surface: anything expressible as `EXEC[foo]:bar` was expressible as `EXEC[sh]:foo bar`. The 501 remains for a fall-through-ineligible dispatch (sh itself unregistered or workspace-disabled).
634
641
 
635
- **Timeout and poll — `<T,P>` on the `<L>` slot (grammar 0.74.20).** EXEC repurposes the line-marker slot as `<timeout, poll>` in **seconds** (consistent with the `seconds=` stream-age render). `T` (mark[0]) caps the spawn's lifetime: at `T>0` the service aborts it (a bounded reap — polite signal then SIGKILL after `PLURNK_SERVICE_EXEC_KILL_GRACE_MS`) and stamps the stream **504**, distinct from a deliberate kill (499) or a clean exit (200). `-1` / absent → unbounded (loop-life bounded), the background-stream behavior. **`0` → turn-scoped**: the stream is reaped at the run's *next pre-turn* (via the registry abort, before the turn's own spawns), so it never survives into the subsequent turn; its terminal output surfaces born-OPEN like any close (§exec-stream). {§exec-timeout} `P` (mark[1]) is the **poll cadence**, stored on the subscription: while the loop is *blocked on a `SEND[202]` wait* for that stream (the poll rides the subscription), the daemon arms a per-run timer for the tightest open poll cadence and resumes the blocked loop every P seconds (floored by `PLURNK_SERVICE_EXEC_WAIT_MS` so it can't tick faster than a turn settles) to inspect progress (the same 202→100 resume a stream conclusion uses, §run-lifecycle). It does **nothing while the loop is active** — an active loop already gets the ambient folded stream deltas (§exec-stream), so the poll-wake matters only across a wait. A wait with no polled stream gets no timer (it sleeps until a conclusion or its own deadline wakes it). {§exec-poll}
642
+ **Timeout and poll — `<T,P>` on the `<L>` slot (grammar 0.74.20).** EXEC repurposes the line-marker slot as `<timeout, poll>` in **seconds** (consistent with the `seconds=` stream-age render). `T` (mark[0]) caps the spawn's lifetime: at `T>0` the service aborts it (a bounded reap — polite signal then SIGKILL after `PLURNK_SERVICE_EXEC_KILL_GRACE_MS`) and stamps the stream **504**, distinct from a deliberate kill (499) or a clean exit (200). `-1` / absent → unbounded (loop-life bounded), the background-stream behavior. **`0` → turn-scoped**: the stream is reaped at the worker's *next pre-turn* (via the registry abort, before the turn's own spawns), so it never survives into the subsequent turn; its terminal output surfaces born-OPEN like any close (§exec-stream). {§exec-timeout} `P` (mark[1]) is the **poll cadence**, stored on the subscription: while the loop is *blocked on a `SEND[202]` wait* for that stream (the poll rides the subscription), the daemon arms a per-worker timer for the tightest open poll cadence and resumes the blocked loop every P seconds (floored by `PLURNK_SERVICE_EXEC_WAIT_MS` so it can't tick faster than a turn settles) to inspect progress (the same 202→100 resume a stream conclusion uses, §worker-lifecycle). It does **nothing while the loop is active** — an active loop already gets the ambient folded stream deltas (§exec-stream), so the poll-wake matters only across a wait. A wait with no polled stream gets no timer (it sleeps until a conclusion or its own deadline wakes it). {§exec-poll}
636
643
 
637
- **Effect-gating.** Each executor declares an `effect` (`pure` | `read` | `host`); the service maps it to policy (`EffectPolicy`). A `host` runtime (subprocess; file-backed sqlite) mutates the host → **propose** (lifecycle §proposal): the run waits for a human gate, then spawns and writes stdout/stderr to channels of a `<runtime>:///<loop>/<turn>/<seq>` entry (the runtime tag is the URI scheme, §exec/#240; the coordinate matches the op's log-row coordinate, e.g. `sh:///1/1/2`), returning `102 Processing` immediately. Channel state transitions (`active` → `closed`/`errored`) drive what the model sees at subsequent turn boundaries (§channel-state). {§exec-host-proposes}
644
+ **Effect-gating.** Each executor declares an `effect` (`pure` | `read` | `host`); the service maps it to policy (`EffectPolicy`). A `host` runtime (subprocess; file-backed sqlite) mutates the host → **propose** (lifecycle §proposal): the worker waits for a human gate, then spawns and writes stdout/stderr to channels of a `<runtime>:///<loop>/<turn>/<seq>` entry (the runtime tag is the URI scheme, §exec/#240; the coordinate matches the op's log-row coordinate, e.g. `sh:///1/1/2`), returning `102 Processing` immediately. Channel state transitions (`active` → `closed`/`errored`) drive what the model sees at subsequent turn boundaries (§channel-state). {§exec-host-proposes}
638
645
 
639
646
  A `read` runtime (observes external state, e.g. search) or `pure` runtime (no observable effect, e.g. `:memory:` sqlite) is side-effect-free → **auto-run**: no proposal, no human gate, no notification. It skips the gate a host command faces, but it does NOT resolve in-band — like every exec it backgrounds and streams, its output reaching the model through the environment-observation injector (a foisted READ of the stream's new bytes each turn, §exec-stream), never a same-turn receipt. {§exec-readpure-ungated}
640
647
 
641
- **Stream surfacing.** An exec's output is *observed, not fetched*. Each turn the environment-observation injector — the same machine §env-delta rides — reads each of the run's open channels from a per-channel byte cursor and foists the new bytes as an `origin=plurnk` READ at `<runtime>:///<coord>#<channel>`, then advances the cursor — each delta carries the `startLine` that cursor implies, so a stream spanning turns numbers into one continuous sequence (lines 1–k, then k+1–m), not a fresh `1:` each turn. The delta is **folded** while the channel streams and auto-**opened** on the terminal one (the channel closed): a model ignores a chatty background run but always SEES a finished one. It never types these READs — it consumes them. The EXEC row itself renders the *command* it ran, `:::`-fenced and line-numbered per §render-rule so the model can line-reference its own code — the input, distinct from the stream above (the output). This is exec as an instance of one ambient machine, env-delta as another (sibling edits, timestamp cursor, always folded). {§exec-stream}
648
+ **Stream surfacing.** An exec's output is *observed, not fetched*. Each turn the environment-observation injector — the same machine §env-delta rides — reads each of the worker's open channels from a per-channel byte cursor and foists the new bytes as an `origin=plurnk` READ at `<runtime>:///<coord>#<channel>`, then advances the cursor — each delta carries the `startLine` that cursor implies, so a stream spanning turns numbers into one continuous sequence (lines 1–k, then k+1–m), not a fresh `1:` each turn. The delta is **folded** while the channel streams and auto-**opened** on the terminal one (the channel closed): a model ignores a chatty background stream but always SEES a finished one. It never types these READs — it consumes them. The EXEC row itself renders the *command* it ran, `:::`-fenced and line-numbered per §render-rule so the model can line-reference its own code — the input, distinct from the stream above (the output). This is exec as an instance of one ambient machine, env-delta as another (sibling edits, timestamp cursor, always folded). {§exec-stream}
642
649
 
643
650
  `SEND[499](exec:///<loop>/<turn>/<seq>)` cancels the in-flight subprocess via the subscription registry's stored `AbortController` — the coordinate addresses the spawn (`exec://` is the process-control face); the `<runtime>://` output entry delegates the same KILL to the one `Exec` handler that owns the abort state (§stream-control).
644
651
 
645
652
  **Scoped environment.** An EXEC subprocess inherits the *project's* environment — its `.env`, the standard shell vars — so the model's commands run as the project expects; but never plurnk's own secrets: the provider API keys and `PLURNK_*` config are stripped before the spawn, so a model-run command can't `printenv` the engine's keys. The service owns the scoping policy (the denylist); the executor spawns with the env it is handed. {§exec-env-scoped}
646
653
  - **The turn-hold exception** {§exec-hold-until-concluded} — for runtimes in `PLURNK_SERVICE_EXEC_HOLD` (a decision-table env, shipped listing the search family), an in-flight stream **pauses the cycle**: the next packet does not assemble until the stream concludes, so the model never burns a turn asking "are we there yet" about a result the engine controls end-to-end (one final JSON digest, seconds-bounded — the owner's ruling: this is the one special case where the stream is known well enough not to fall back on the standard cycle). Bounded by `PLURNK_SERVICE_EXEC_HOLD_MS` and **fail-open**: at the cap the standard cycle resumes untouched (waits, wakes, polls). Zero grammar or teaching surface — the model emits `EXEC + SEND[102]` as ever; the wake-shaped world simply arrives one packet sooner. Lives at the post-EXEC breath seam in `runLoop`, upstream of `PLURNK_SERVICE_EXEC_WAIT_MS`.
647
- - **The entry() sink** {§exec-entry-sink} — an executor may *request* entry materialization (execs SPEC §2.6: every sink is a consumer-implemented callback; the executor owns zero substrate). The service implements it in exec dispatch: `entry(path, content, {tags, mimetype})` upserts the entry (writeEntry; tags **UNIONED** across writes — a re-seen URL keeps its history of query slugs), then narrates ONE `EDIT` row in the reserved `plurnk` run's log — the fs-fiction pattern, `source` = the calling run, `tokens` = the content's count, `attrs` carrying the tags — which the env-delta ambience (§env-delta) folds into every run's next packet as a one-liner. **No page body ever rides a packet**; the announcement is the folded meta line (path + tokens + tags), and the model READs/~queries what it chooses. Parallel `entry()` calls serialize on a per-spawn chain; a rejected call prunes that item executor-side (zero-dead-rows covers storage failure) without breaking the chain. The narration context (one plurnk-run turn) is lazy per spawn, not per entry. Born of the Web Search Epic's one-load flow (#340): search fetches once, materializes survivors tagged with the query slug, and writes the digest of survivors only.
654
+ - **The entry() sink** {§exec-entry-sink} — an executor may *request* entry materialization (execs SPEC §2.6: every sink is a consumer-implemented callback; the executor owns zero substrate). The service implements it in exec dispatch: `entry(path, content: string | null, {tags, mimetype?})` upserts the entry (writeEntry; tags **UNIONED** across writes — a re-seen URL keeps its history of query slugs), then narrates ONE `EDIT` row in the reserved `plurnk` worker's log — the fs-fiction pattern, `source` = the calling worker, `tokens` = the content's count, `attrs` carrying the tags — which the env-delta ambience (§env-delta) folds into every worker's next packet as a one-liner. **The executor owns no fetcher** (Web Search ruling #5): a `content: null` is a *declaration* — the service fetches the page once through schemes-http's guarded primitive (`WebFetcher`: SSRF-guarded fetch+render, dead-as-`null`) and materializes it, and a dead fetch (guard-refused / unreachable / non-2xx / non-textual / empty) **rejects the sink** so the executor prunes that row. A non-null `content` is the materialize-given-body path (the caller already holds the bytes and states their mimetype). **No page body ever rides a packet**; the announcement is the folded row's meta (path + tokens + tags), and the model READs/~queries what it chooses. Parallel `entry()` calls serialize on a per-spawn chain; a rejected call prunes that item without breaking the chain. The narration context (one plurnk-worker turn) is lazy per spawn, not per entry. Born of the Web Search Epic's one-load flow (#340/#455): the executor declares each candidate, the service fetches survivors once, tags them with the query slug, and the digest lists survivors only.
648
655
 
649
656
  ### §proposal The proposal lifecycle
650
657
 
@@ -676,11 +683,11 @@ Streams are static content from the engine's perspective — content arrives ove
676
683
 
677
684
  ### §subscriptions Subscriptions
678
685
 
679
- READ on a streaming scheme is a subscription, not a one-shot. Scheme opens the connection (SSE/WS/subprocess), returns `102 Processing` immediately, stays alive. Engine records `(sessionId, entryId) → schemeName + handle` in a subscription registry so `SEND[499]` cancellation routes to the owning scheme. {§subscriptions-subscription-registry-routes-cancellation}
686
+ READ on a streaming scheme is a subscription, not a one-shot. Scheme opens the connection (SSE/WS/subprocess), returns `102 Processing` immediately, stays alive. Engine records `(workspaceId, entryId) → schemeName + handle` in a subscription registry so `SEND[499]` cancellation routes to the owning scheme. {§subscriptions-subscription-registry-routes-cancellation}
680
687
 
681
688
  Subscription registry is plurnk-service runtime state (its own SQLite table). Exists ONLY for cancellation routing. Channel state (§channel-state) + log entries (§no-chunk-rows) carry lifecycle.
682
689
 
683
- FOLD/OPEN toggles `log_entries.expanded` (§open-fold) — a per-run render bit, never the subscription registry. FOLDing a streaming entry's log row collapses its body out of the packet but leaves the live stream running: curation is render-only, never cancellation. {§subscriptions-fold-keeps-subscription}
690
+ FOLD/OPEN toggles `log_entries.expanded` (§open-fold) — a per-worker render bit, never the subscription registry. FOLDing a streaming entry's log row collapses its body out of the packet but leaves the live stream running: curation is render-only, never cancellation. {§subscriptions-fold-keeps-subscription}
684
691
 
685
692
  ### §chunk-accumulation Chunk accumulation
686
693
 
@@ -739,7 +746,7 @@ No generator. SQLite-optimal: STRICT (3.37+), `INTEGER PRIMARY KEY` aliasing, ex
739
746
  - Entry-vs-log integrity.
740
747
 
741
748
  **Lives in TS:**
742
- - Status-bubble rules (`turn.status` → `loop.status` → `run.status` → `session.status`). Engine UPDATEs explicitly; CHECK constraints enforce; triggers fight branching state machines.
749
+ - Status-bubble rules (`turn.status` → `loop.status` → `worker.status` → `workspace.status`). Engine UPDATEs explicitly; CHECK constraints enforce; triggers fight branching state machines.
743
750
  - Tokenization (provider-bound; hot-swap re-tokenizes per §tokenomics).
744
751
  - Provider dispatch + response normalization.
745
752
  - Scheme-handler invocation (connections, subprocesses, fetch).
@@ -783,11 +790,11 @@ Plugin discovery (§plugin-discovery) registers whatever's in `node_modules/@plu
783
790
 
784
791
  | In-tree | Future sibling | Notes |
785
792
  |---|---|---|
786
- | `Known.ts` | `@plurnk/plurnk-schemes-known` | Primary narrative entries; session-scoped. |
793
+ | `Known.ts` | `@plurnk/plurnk-schemes-known` | Primary narrative entries; workspace-scoped. |
787
794
  | `Unknown.ts` | `@plurnk/plurnk-schemes-unknown` | Open questions / decomposition. |
788
795
  | `Skill.ts` | `@plurnk/plurnk-schemes-skill` | Skill docs; same shape as known. |
789
796
  | `Plurnk.ts` | may stay in-tree | `plurnk:///prompt/<loop_id>` carries each loop's prompt. Model-origin writes to `plurnk:///prompt/*` rejected in-handler. |
790
- | `Log.ts` | may stay in-tree | Read-only coordinate-addressed (`log:///<L>/<T>/<S>`). Renders as a JSON meta line in the `log` section; status ≥ 400 mirrors to the `errors` section (§telemetry). |
797
+ | `Log.ts` | may stay in-tree | Read-only coordinate-addressed (`log:///<L>/<T>/<S>`). Renders as a `jsonplurnk` object in the `log` section; status ≥ 400 mirrors to the `errors` section (§telemetry). |
791
798
  | `File.ts` | `@plurnk/plurnk-schemes-file` | Filesystem-backed. **Model is never trained on `file:///` and never sees it.** Bare paths are model-facing; `file:///` accepted as input, renders bare. |
792
799
  | `Exec.ts` | stays in-tree | Dispatches EXEC op to runtime executors registered via [plurnk-execs](https://github.com/plurnk/plurnk-execs). |
793
800
 
@@ -825,44 +832,42 @@ Env-var cascade: the assembled `.env.defaults` floor < `~/.plurnk/.env` < `./.en
825
832
 
826
833
  Model selection: separate alias cascade in `ProviderRegistry` (§provider-instantiation). `PLURNK_MODEL_<alias>=<provider>/<model-id>` declares; `PLURNK_MODEL=<alias>` selects. Aliases live in `.env`, not `.env.defaults` (operator-specific).
827
834
 
828
- | Var | Default | Status | Purpose |
829
- |--------------------------------------|--------------------|------------|---------------------------------------------------------------|
830
- | `PLURNK_SERVICE_DB_PATH` | `./plurnk.db` | enforced | SQLite file path. |
831
- | `PLURNK_HOST` | `127.0.0.1` | enforced | Bind address for the listener. Local-only by default. |
832
- | `PLURNK_PORT` | `3044` | enforced | TCP port for THE client surface — the AG-UI+ listener (the plurnk-agui daughter module binds it at boot). Production is single-listener. |
833
- | `PLURNK_SERVICE_MAX_TURNS` | `-1` | enforced | Operator turn **ceiling** — `-1` = no cap; a positive value caps a per-call `loop.run({maxTurns})`. |
834
- | `PLURNK_SERVICE_MAX_COMMANDS` | `99` | enforced | Per-emission op cap. Overflow ops drop silently; one `max_commands_exceeded` telemetry entry surfaces on the next packet. |
835
- | `PLURNK_SERVICE_LOOP_TIMEOUT` | `86400000` | enforced | ms wall-clock budget for a single `loop.run`: expiry aborts the loop signal mid-flight (a stuck `generate` included) and the loop terminates `504 loop_timeout` — a legible engine terminal, kin to the exec `<T>` reap's 504 (§exec-timeout). {§operator-config-loop-timeout} |
836
- | `PLURNK_SERVICE_MAX_STRIKES` | `3` | enforced | Strike threshold + sudden-death lead time (§engine-rails). |
837
- | `PLURNK_SERVICE_MIN_CYCLES` | `3` | enforced | Min repetitions before cycle detection fires (§engine-rails). |
838
- | `PLURNK_SERVICE_MAX_CYCLE_PERIOD` | `4` | enforced | Max period length cycle detection examines (§engine-rails). |
839
- | `PLURNK_SERVICE_MD_<ALIAS>` | (unset) | enforced | Operator reference doc: materializes `<path>` as `plurnk:///<ALIAS>.md`, auto-READ into every model run's turn 0 (§actor-boundary). `~` expands to home. |
840
- | `PLURNK_SERVICE_FILES_ITEMS` | `-1` | enforced | Turn-0 catalog preview, one `FIND(scheme:///**)` per scheme. Memory/scratch/docs always full; the first-`N` cap applies **only** to the `file` list. `-1` = all full; positive `N` = file list first-N (memory still full); `0` / unset = off (§actor-boundary-catalog-preview). |
841
- | `PLURNK_SERVICE_PROPOSAL_TIMEOUT_MS` | `300000` | enforced | ms wait for a proposed entry (status=202) to be resolved before timing out. |
842
- | `PLURNK_PROVIDERS_THINKING` + `_CAPACITY` | `on` / `4096` | enforced | The activation/capacity split (providers 0.31; a numeric budget silently flipping template flags was secret flag-setting). `off | adaptive | on`; capacity (tokens) REQUIRED when on, shipped equal to the partition's REASONING reserve (template-pinned). A think-trained model MUST think — off reroutes its thought into the grammar's legal free zone as prose. One setting is right everywhere: providers clamp thinking to none on in-band (response_format) grammar calls themselves, so the channel-topology parallax needs no per-backend override. F7 coupling: llama-server honors only the box's `--reasoning-budget` launch flag (must equal capacity; boot-warned). |
843
- | `PLURNK_PROVIDERS_FETCH_TIMEOUT` | `600000` | enforced | Service-wide ms ceiling on any outbound request (providers, future http schemes). Module-specific overrides are allowed below the ceiling. |
844
- | `PLURNK_SERVICE_DEBUG` | `0` | reserved | Schema-validation toggle. Not yet enforced. |
845
- | `PLURNK_SERVICE_LOG_LEVEL` | `info` | reserved | Stdout banner verbosity. Not yet enforced. |
846
-
847
- **enforced** = engine reads and acts on the value. **reserved** = shipped in `.env.defaults` (forward-spec) but no-op until wired.
835
+ | Var | Default | Purpose |
836
+ |-----|---------|---------|
837
+ | `PLURNK_SERVICE_DB_PATH` | `~/.plurnk/plurnk.db` | SQLite file path. |
838
+ | `PLURNK_HOST` | `127.0.0.1` | Bind address for the listener. Local-only by default. |
839
+ | `PLURNK_PORT` | `3044` | TCP port for THE client surface — the AG-UI+ listener (the plurnk-agui daughter module binds it at boot). Production is single-listener. |
840
+ | `PLURNK_SERVICE_MAX_TURNS` | `-1` | Operator turn **ceiling** — `-1` = no cap; a positive value caps a per-call `loop.run({maxTurns})`. |
841
+ | `PLURNK_SERVICE_MAX_COMMANDS` | `-1` | Per-emission op ceiling; `-1` = no cap (default) — every generated op dispatches. A positive value caps dispatched actions: overflow ops drop silently with one `max_commands_exceeded` telemetry entry on the next packet. Tightened per workspace via `settings.maxCommands` (min wins). |
842
+ | `PLURNK_SERVICE_LOOP_TIMEOUT` | `86400000` | ms wall-clock budget for a single `loop.run`: expiry aborts the loop signal mid-flight (a stuck `generate` included) and the loop terminates `504 loop_timeout` — a legible engine terminal, kin to the exec `<T>` reap's 504 (§exec-timeout). {§operator-config-loop-timeout} |
843
+ | `PLURNK_SERVICE_MAX_STRIKES` | `3` | Strike threshold + sudden-death lead time (§engine-rails). |
844
+ | `PLURNK_SERVICE_MIN_CYCLES` | `3` | Min repetitions before cycle detection fires (§engine-rails). |
845
+ | `PLURNK_SERVICE_MAX_CYCLE_PERIOD` | `4` | Max period length cycle detection examines (§engine-rails). |
846
+ | `PLURNK_SERVICE_MD_<ALIAS>` | (unset) | Operator reference doc: materializes `<path>` as `plurnk:///<ALIAS>.md`, auto-READ into every model worker's turn 0 (§actor-boundary). `~` expands to home. |
847
+ | `PLURNK_SERVICE_FILES_ITEMS` | `-1` | Turn-0 catalog preview, one `FIND(scheme:///**)` per scheme. Memory/scratch/docs always full; the first-`N` cap applies **only** to the `file` list. `-1` = all full; positive `N` = file list first-N (memory still full); `0` / unset = off (§actor-boundary-catalog-preview). |
848
+ | `PLURNK_SERVICE_PROPOSAL_TIMEOUT_MS` | (empty — waits indefinitely) | ms wait for a proposed entry (status=202) to be resolved before timing out. |
849
+ | `PLURNK_PROVIDERS_THINKING` + `_CAPACITY` | `on` / `4096` | The activation/capacity split (providers 0.31; a numeric budget silently flipping template flags was secret flag-setting). `off | adaptive | on`; capacity (tokens) REQUIRED when on, shipped equal to the partition's REASONING reserve (template-pinned). A think-trained model MUST think — off reroutes its thought into the grammar's legal free zone as prose. One setting is right everywhere: providers clamp thinking to none on in-band (response_format) grammar calls themselves, so the channel-topology parallax needs no per-backend override. F7 coupling: llama-server honors only the box's `--reasoning-budget` launch flag (must equal capacity; boot-warned). |
850
+ | `PLURNK_PROVIDERS_FETCH_TIMEOUT` | `600000` | Service-wide ms ceiling on any outbound request (providers, future http schemes). Module-specific overrides are allowed below the ceiling. |
851
+
852
+ Every knob listed is enforced the engine reads and acts on it; `.env.defaults` is the authoritative default (reader-declares, §operator-config-env-defaults).
848
853
 
849
854
  **Two override semantics — ceiling vs default.** Which kind a var is determines what "override" means across the cascade:
850
- - **Ceiling** (most-restrictive-wins) — an operator-set hard bound nothing downstream may exceed: not a lower-precedence file, not a per-session constraint, not a per-call RPC arg. `PLURNK_SERVICE_GIT_ALLOWED` (`=0` flatly denies git service-wide, §membership), `PLURNK_SERVICE_MAX_COMMANDS`, `PLURNK_SERVICE_MAX_STRIKES`, `PLURNK_PROVIDERS_FETCH_TIMEOUT` (module overrides allowed only *below* it), and `PLURNK_SERVICE_MAX_TURNS` (`-1` ships it off; a positive value caps the per-call request). The sandbox/cost guarantee: the operator caps it; no client widens it.
855
+ - **Ceiling** (most-restrictive-wins) — an operator-set hard bound nothing downstream may exceed: not a lower-precedence file, not a per-workspace constraint, not a per-call RPC arg. `PLURNK_SERVICE_GIT_ALLOWED` (`=0` flatly denies git service-wide, §membership), `PLURNK_SERVICE_MAX_COMMANDS`, `PLURNK_SERVICE_MAX_STRIKES`, `PLURNK_PROVIDERS_FETCH_TIMEOUT` (module overrides allowed only *below* it), and `PLURNK_SERVICE_MAX_TURNS` (`-1` ships it off; a positive value caps the per-call request). The sandbox/cost guarantee: the operator caps it; no client widens it.
851
856
  - **Default** (explicit-wins) — a fallback the most-specific setter replaces freely: `PLURNK_MODEL` (a `loop.run({alias})` overrides it), `PLURNK_SERVICE_REQUIREMENTS` (the per-call requirements default), and the config-time vars (`HOST` / `PORT` / `DB_PATH`).
852
857
 
853
858
  **The shipped `.env.defaults` is itself under test** (no active `PLURNK_SERVICE_MD_*` doc alias — the policy is a SECTION, a doc default double-injects it; no active `PLURNK_MODEL`; a per-alias `PLURNK_PROVIDERS_GBNF` (bare OFF, the three GBNF-capable aliases opting in); the policy renders in exactly one packet section): every other tier runs the test cascade, so shipped-default regressions are invisible to it by construction. {§operator-config-shipped-defaults} Its companion **flag-parity** check binds code and template both ways: every `PLURNK_SERVICE_*` the service reads has a `.env.defaults` line (a floor, a `--flag`, a legend entry) and every declared `PLURNK_SERVICE_*` is read — so a half-landed rename (a missed file, a script-glob gap) fails a test instead of a user's boot, and a dead knob can't ship. {§operator-config-flag-parity}
854
859
 
855
860
  Enforcement is per-use-site — no central most-restrictive pass; each ceiling is checked where it bites. `PLURNK_SERVICE_MAX_TURNS` ships **off** (`-1` = no cap; the loop ends via SEND, budget, strikes, or cycle detection) and, when an operator sets a positive value, the per-call request is `min()`-capped against it. {§operator-config-max-turns-ceiling}
856
861
 
857
- **Client open-context (per session).** `session.create({settings})` carries per-session overrides, persisted on `sessions.settings` and composed against env at each knob's read-site. Two families, kept distinct so neither semantic leaks into the other; operator-arcane knobs stay env-only — this is the narrow client surface.
862
+ **Client open-context (per workspace).** `workspace.create({settings})` carries per-workspace overrides, persisted on `workspaces.settings` and composed against env at each knob's read-site. Two families, kept distinct so neither semantic leaks into the other; operator-arcane knobs stay env-only — this is the narrow client surface.
858
863
 
859
864
  *Defaults — explicit-wins (the client replaces/merges freely):*
860
- - `settings.filesItems` (number) **replaces** `PLURNK_SERVICE_FILES_ITEMS` for the session: a one-shot opens clean (`0`, no preview), a workspace full (`-1`), or with the file list capped (`N`, memory still full). A single scalar — the client value wins outright. {§operator-config-session-files-items}
861
- - `settings.mdDocs` (`[{alias, content}]`) **unions** with the server's `PLURNK_SERVICE_MD_*` docs, keyed by alias — a client adds its own repo docs atop the operator's systemwide policy doc. On alias collision the client wins (a deliberate shadow), but by default the policy doc rides into every session. The client sends content (it owns the file), not a path. {§operator-config-session-md-docs}
865
+ - `settings.filesItems` (number) **replaces** `PLURNK_SERVICE_FILES_ITEMS` for the workspace: a one-shot opens clean (`0`, no preview), a workspace full (`-1`), or with the file list capped (`N`, memory still full). A single scalar — the client value wins outright. {§operator-config-workspace-files-items}
866
+ - `settings.mdDocs` (`[{alias, content}]`) **unions** with the server's `PLURNK_SERVICE_MD_*` docs, keyed by alias — a client adds its own repo docs atop the operator's systemwide policy doc. On alias collision the client wins (a deliberate shadow), but by default the policy doc rides into every workspace. The client sends content (it owns the file), not a path. {§operator-config-workspace-md-docs}
862
867
 
863
868
  *Ceilings — most-restrictive-wins (the client may only narrow, never widen):*
864
- - `settings.maxCommands` (number) **min()s** the `PLURNK_SERVICE_MAX_COMMANDS` per-emission cap for the session — a client tightens the runaway-op guard, never raises it past the operator's. {§operator-config-session-max-commands} The cap bounds *actions* only: PLAN (reasoning) and a terminal `SEND` (signal ≥ 200, the conclusion) are never counted and always dispatch — so `0` is a valid floor (the tightest), admitting a plan and a conclusion with zero actions. {§operator-config-session-max-commands-floor}
865
- - `settings.git` (`false`) **denies** git for the session (`PLURNK_SERVICE_GIT_ALLOWED` AND session) — the client opts its session out of git membership + telemetry; it can never re-enable git past the operator's service-wide lockout. {§operator-config-session-git}
869
+ - `settings.maxCommands` (number) **min()s** the `PLURNK_SERVICE_MAX_COMMANDS` per-emission cap for the workspace — a client tightens the runaway-op guard, never raises it past the operator's. {§operator-config-workspace-max-commands} The cap bounds *actions* only: PLAN (reasoning) and a terminal `SEND` (signal ≥ 200, the conclusion) are never counted and always dispatch — so `0` is a valid floor (the tightest), admitting a plan and a conclusion with zero actions. {§operator-config-workspace-max-commands-floor}
870
+ - `settings.git` (`false`) **denies** git for the workspace (`PLURNK_SERVICE_GIT_ALLOWED` AND workspace) — the client opts its workspace out of git membership + telemetry; it can never re-enable git past the operator's service-wide lockout. {§operator-config-workspace-git}
866
871
 
867
872
  Feature-flag bools use `process.env.X === "1"` exactly — never `=== "true"`.
868
873
 
@@ -911,7 +916,7 @@ registry.registerMethod("loop.run", {
911
916
 
912
917
  - `description`: one-liner surfaced by `discover`.
913
918
  - `params`: `"type — meaning"` per param; `?` suffix = optional. Self-documenting, not enforced.
914
- - `requiresInit`: rejects until a session is attached.
919
+ - `requiresInit`: rejects until a workspace is attached.
915
920
 
916
921
  ### §discovery Discovery
917
922
 
@@ -952,34 +957,34 @@ registry.registerMethod("loop.run", {
952
957
  | `ping` | none | `{}` | No init required. |
953
958
  | `discover` | none | catalog (§discovery) | No init required. |
954
959
 
955
- **Sessions**
960
+ **Workspaces**
956
961
 
957
962
  | Method | Params | Result | Notes |
958
963
  |------------------------|---------------------|-------------------|-------|
959
- | `session.create` | `name?: string`, `projectRoot?: string`, `settings?: object` | `{ id, name, runId, runName, projectRoot }` | Creates new session + its first run; auto-name if unprovided. Returns the auto-created run's identity so clients skip the pending-dance ({§methods-session-create}). Optional `projectRoot` pins the workspace — **set here or never; headless is forever** (the pointer is immutable, so membership is established exactly once, at creation); null/omitted = headless. Optional `settings` carries per-session open-context overrides (§operator-config). |
960
- | `session.list` | none | `{ sessions: Session[] }` | Lists all sessions. |
961
- | `session.attach` | `id: number`, `runId?: number`, `runName?: string` | `{ id, name, runId, runName }` | Binds this connection to an existing session. Optional `runId` resumes that specific run (must belong to the session). Optional `runName` reuses-or-creates by name within the session. Both omitted → new auto-named run. {§methods-session-attach} |
962
- | `session.runs` | `id?: number` | `{ runs: Run[] }` | Lists runs in a session (defaults to attached session); most-recent first. |
963
- | `session.prompts` | `id?: number`, `limit?: number` | `{ prompts: string[] }` | A session's prior user prompts (the conversation run's loop seeds), newest-first, capped by `limit` (default 100); defaults to attached session. Lets a client seed up/down recall without log archaeology. |
964
- | `session.rename` | `name: string` | `{ id, name }` | Rename the attached session — its name is a **mutable handle** on the world (unlike a run, whose name is frozen at instantiation, §machine-processes). Mutates `sessions.name` only; runs, log, and membership untouched. A name another session holds is rejected (`sessions.name` is unique). {§methods-session-rename} |
965
- | `session.constrain` | `effect: "pick" \| "hide" \| "view" \| "repo"`, `glob: string` | `{ effect, glob }` | Add a workspace membership constraint (§membership overlay): `pick` admits a file git misses (the sole source when git is absent), `hide` drops a tracked match, `view` admits a member read-only (refused at the edit gate), `repo` declares a git repo folder anywhere so its members join the manifest. Immediate. |
966
- | `session.unconstrain` | `effect: "pick" \| "hide" \| "view" \| "repo"`, `glob: string` | `{ effect, glob }` | Remove a membership constraint (the `drop` verb) — the inverse of `session.constrain`. Immediate. |
967
- | `session.constraints` | none | `{ constraints }` | List the attached session's membership constraints. |
968
- | `session.members` | none | `{ members: [{ path, effect }], hidden }` | Resolve each project file's membership effect — `members` tagged `member`/`view` plus the `hide`-excluded `hidden` — so a client signs file visibility (member / read-only / ignored) without reimplementing the overlay glob-matching (§membership-resolved-effects). |
964
+ | `workspace.create` | `name?: string`, `projectRoot?: string`, `settings?: object` | `{ id, name, workerId, workerName, projectRoot }` | Creates new workspace + its first worker; auto-name if unprovided. Returns the auto-created worker's identity so clients skip the pending-dance ({§methods-workspace-create}). Optional `projectRoot` pins the workspace — **set here or never; headless is forever** (the pointer is immutable, so membership is established exactly once, at creation); null/omitted = headless. Optional `settings` carries per-workspace open-context overrides (§operator-config). |
965
+ | `workspace.list` | none | `{ workspaces: Workspace[] }` | Lists all workspaces. |
966
+ | `workspace.attach` | `id: number`, `workerId?: number`, `workerName?: string` | `{ id, name, workerId, workerName }` | Binds this connection to an existing workspace. Optional `workerId` resumes that specific worker (must belong to the workspace). Optional `workerName` reuses-or-creates by name within the workspace. Both omitted → new auto-named worker. {§methods-workspace-attach} |
967
+ | `workspace.workers` | `id?: number` | `{ workers: Worker[] }` | Lists workers in a workspace (defaults to attached workspace); most-recent first. |
968
+ | `workspace.prompts` | `id?: number`, `limit?: number` | `{ prompts: string[] }` | A workspace's prior user prompts (the conversation worker's loop seeds), newest-first, capped by `limit` (default 100); defaults to attached workspace. Lets a client seed up/down recall without log archaeology. |
969
+ | `workspace.rename` | `name: string` | `{ id, name }` | Rename the attached workspace — its name is a **mutable handle** on the world (unlike a worker, whose name is frozen at instantiation, §machine-processes). Mutates `workspaces.name` only; workers, log, and membership untouched. A name another workspace holds is rejected (`workspaces.name` is unique). {§methods-workspace-rename} |
970
+ | `workspace.constrain` | `effect: "pick" \| "hide" \| "view" \| "repo"`, `glob: string` | `{ effect, glob }` | Add a workspace membership constraint (§membership overlay): `pick` admits a file git misses (the sole source when git is absent), `hide` drops a tracked match, `view` admits a member read-only (refused at the edit gate), `repo` declares a git repo folder anywhere so its members join the manifest. Immediate. |
971
+ | `workspace.unconstrain` | `effect: "pick" \| "hide" \| "view" \| "repo"`, `glob: string` | `{ effect, glob }` | Remove a membership constraint (the `drop` verb) — the inverse of `workspace.constrain`. Immediate. |
972
+ | `workspace.constraints` | none | `{ constraints }` | List the attached workspace's membership constraints. |
973
+ | `workspace.members` | none | `{ members: [{ path, effect }], hidden }` | Resolve each project file's membership effect — `members` tagged `member`/`view` plus the `hide`-excluded `hidden` — so a client signs file visibility (member / read-only / ignored) without reimplementing the overlay glob-matching (§membership-resolved-effects). |
969
974
 
970
- **Re-binding.** `session.create` and `session.attach` may be called on a connection that already has a session attached — the connection switches in place, releasing the prior client loop (closed at 200). No reconnect needed to change session or run. {§methods-rebind}
975
+ **Re-binding.** `workspace.create` and `workspace.attach` may be called on a connection that already has a workspace attached — the connection switches in place, releasing the prior client loop (closed at 200). No reconnect needed to change workspace or run. {§methods-rebind}
971
976
 
972
977
 
973
- **Reserved run names.** `plurnk` is reserved for the runtime actor (§authority-terms). `session.attach` rejects it — case-insensitively, *before* the lookup-or-create — so a client can neither forge a `plurnk` run nor resume the runtime's, closing impersonation of `origin=plurnk`. The auto-namer never emits a reserved name. {§methods-run-name-reserved}
978
+ **Reserved worker names.** `plurnk` is reserved for the runtime actor (§authority-terms). `workspace.attach` rejects it — case-insensitively, *before* the lookup-or-create — so a client can neither forge a `plurnk` worker nor resume the runtime's, closing impersonation of `origin=plurnk`. The auto-namer never emits a reserved name. {§methods-worker-name-reserved}
974
979
 
975
980
  **Loops (model-driven)**
976
981
 
977
982
  | Method | Params | Result | Notes |
978
983
  |-------------------|-------------------------------------|------------------------|-------|
979
- | `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `flags?: LoopFlags` | `{ loopId, action, finalStatus: 100 }` | Model-driven loop. **Accepts and returns immediately** (`finalStatus: 100`; `action` = `enqueued_new_loop` \| `injected_next_turn`) — it never blocks on the loop, which may block on a `SEND[202]` wait for its own spawned work (§run-lifecycle-wake-liveness). The loop's outcome — `finalStatus`, `turnIds`, `hitMaxTurns`, `usage` — arrives on the **`loop/terminated`** event. Optional `alias` overrides the boot-time `PLURNK_MODEL`. Optional `flags` carries per-loop flags (`{yolo?: boolean}`; more as wired — see §engine-rails). Streams `log/entry` and `loop/proposal` during. `longRunning: false`. {§methods-loop-run} |
980
- | `proposal.list` | — | `{ proposals: [{logEntryId, runId, loopId, turnId, op, suffix, target, body, attrs, flags, at}] }` | Every pending (`state='proposed'`) stop-the-world proposal in the attached session — the indefinite-wait ruling's mandatory companion: `loop/proposal` is a notification, so a client reconnecting during a stopped world (possibly days old) DISCOVERS it here and answers via the ordinary `loop.resolve`. `attrs` carries `{question, choices}` for [300] asks. {§proposal-list} |
984
+ | `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `model?: string`, `flags?: LoopFlags` | `{ loopId, action, finalStatus: 100 }` | Model-driven loop. **Accepts and returns immediately** (`finalStatus: 100`; `action` = `enqueued_new_loop` \| `injected_next_turn`) — it never blocks on the loop, which may block on a `SEND[202]` wait for its own spawned work (§worker-lifecycle-wake-liveness). The loop's outcome — `finalStatus`, `turnIds`, `hitMaxTurns`, `usage` — arrives on the **`loop/terminated`** event. **Per-loop model selection** (#414): optional `model` (client-resolved `<provider>/<model>`, wins) or `alias` (a declared `PLURNK_MODEL_<alias>`) overrides the boot-time `PLURNK_MODEL` for THIS loop — a client sends it every loop, so a switch takes effect turn-to-turn; instances are cached, an unresolvable spec fails loud, neither given runs the boot default. Optional `flags` carries per-loop flags (`{yolo?: boolean}`; more as wired — see §engine-rails). Streams `log/entry` and `loop/proposal` during. `longRunning: false`. {§methods-loop-run} {§methods-loop-run-model} |
985
+ | `proposal.list` | — | `{ proposals: [{logEntryId, workerId, loopId, turnId, op, suffix, target, body, attrs, flags, at}] }` | Every pending (`state='proposed'`) stop-the-world proposal in the attached workspace — the indefinite-wait ruling's mandatory companion: `loop/proposal` is a notification, so a client reconnecting during a stopped world (possibly days old) DISCOVERS it here and answers via the ordinary `loop.resolve`. `attrs` carries `{question, choices}` for [300] asks. {§proposal-list} |
981
986
  | `loop.resolve` | `logEntryId: number`, `decision: "accept" \| "reject" \| "cancel"`, `body?: string`, `outcome?: string` | `{ status, logEntryId }` | Resolve a pending proposal (status=202 log entry). Engine.dispatch unpauses on resolution. |
982
- | `loop.cancel` | `reason?: string` | `{ cancelled, runId, reason }` | Abort the attached run's active drain. `{cancelled: true}` if a drain was running, `{false}` if idle. Cancelled loops close at 499; queued-but-unclaimed loops stay enqueued. Default reason `user_cancelled`. **The cancel is provenanced, never silent** (#380, owner ruling): the loop's terminal ROW is written (499 — a dead loop must never read as live 102, the #311 rule applied to the cancel path) with `terminated_by='cancel'` and the reason as its abandonment message, and the `loop/terminated` broadcast carries the same `message` — an external cancel is allowed (the client owns its loops, even 202-waiting ones with live obligations), but who/why is always on the record. A PARKED (202) loop has no drain to observe the abort, so the cancel terminalizes it directly — same row, same broadcast — even as `cancelled` answers `false` (no in-flight drain existed). {§methods-loop-cancel} |
987
+ | `loop.cancel` | `reason?: string` | `{ cancelled, workerId, reason }` | Abort the attached worker's active drain. `{cancelled: true}` if a drain was running, `{false}` if idle. Cancelled loops close at 499; queued-but-unclaimed loops stay enqueued. Default reason `user_cancelled`. **The cancel is provenanced, never silent** (#380, owner ruling): the loop's terminal ROW is written (499 — a dead loop must never read as live 102, the #311 rule applied to the cancel path) with `terminated_by='cancel'` and the reason as its abandonment message, and the `loop/terminated` broadcast carries the same `message` — an external cancel is allowed (the client owns its loops, even 202-waiting ones with live obligations), but who/why is always on the record. A PARKED (202) loop has no drain to observe the abort, so the cancel terminalizes it directly — same row, same broadcast — even as `cancelled` answers `false` (no in-flight drain existed). {§methods-loop-cancel} |
983
988
  | `providers.list` | none | `{ aliases: ProviderAlias[] }` | Lists configured `PLURNK_MODEL_<alias>` entries with `{alias, provider, model, active}`. Clients use to populate model-selection UI. |
984
989
 
985
990
  **Reads**
@@ -987,7 +992,7 @@ registry.registerMethod("loop.run", {
987
992
  | Method | Params | Result | Notes |
988
993
  |---------------|-------------------------------------|------------------------|-------|
989
994
  | `entry.read` | `target: string` | `{ status, entry }` | Read the full entry shape (channels + tags + metadata) at the given URI. {§methods-entry-read} |
990
- | `log.read` | `loopId?`, `turnId?`, `loopSeq?`, `turnSeq?`, `sequence?`, `sinceId?`, `limit?` | `{ entries: LogEntry[] }` | Read recent log entries from the attached session. A full display coordinate (`loopSeq`+`turnSeq`+`sequence`) resolves the single entry behind an `L/T/S` waterfall line — full shape (tx + rx), server-side, no client fetch-all+match (#271). {§methods-log-read} |
995
+ | `log.read` | `loopId?`, `turnId?`, `loopSeq?`, `turnSeq?`, `sequence?`, `sinceId?`, `limit?` | `{ entries: LogEntry[] }` | Read recent log entries from the attached workspace. A full display coordinate (`loopSeq`+`turnSeq`+`sequence`) resolves the single entry behind an `L/T/S` waterfall line — full shape (tx + rx), server-side, no client fetch-all+match (#271). {§methods-log-read} |
991
996
 
992
997
  **Log coordinate.** Every `LogEntry` — from `log.read` and the `log/entry` notification alike — carries `loop_seq`/`turn_seq`, the loop+turn ordinals, beside the `loop_id`/`turn_id` DB keys, so a client renders the logical coordinate (e.g. `01/02/03`) without resolving ids. {§methods-log-coordinate}
993
998
 
@@ -1012,11 +1017,11 @@ Naming: `target` = URI the op acts on; `scope` for FIND; `source`/`destination`
1012
1017
  | `op.exec` | `cwd?: string`, `runtime?: string`, `command?: string` | Mirrors `<<EXEC>>`. |
1013
1018
  | `op.dispatch` | `statement: PlurnkStatement` | Low-level path for clients that have a parsed AST already (e.g. the TUI when the user types raw HEREDOC at the prompt). |
1014
1019
  | `op.parse` | `text: string` | Convenience: daemon parses raw DSL text via the grammar, dispatches each statement as actions of one turn, returns `{ results: DispatchResult[] }`. |
1015
- | `op.look` | `text: string` | Non-logging READ: resolves the target via READ's full scheme resolver and returns its content, writing **no** log entry. The client's off-run inspection primitive — forward `<<LOOK>>` with the op token rewritten `LOOK`→`READ`. READ-only. {§op-look} |
1020
+ | `op.look` | `text: string` | Non-logging READ: resolves the target via READ's full scheme resolver and returns its content, writing **no** log entry. The client's off-worker inspection primitive — forward `<<LOOK>>` with the op token rewritten `LOOK`→`READ`. READ-only. {§op-look} |
1016
1021
 
1017
1022
  All `op.*` return `{ status, ...op-specific }`. All `requiresInit: true`. None `longRunning`.
1018
1023
 
1019
- **`op.look` is the exception** to the "creates a turn, fires `log/entry`" rule above (§methods-op-mirror): it runs READ's full resolver (every scheme, full grammar — the client stays grammar-blind, forwarding its `<<LOOK>>` text with the op token swapped to `READ`) but mints **no turn and writes no `log_entries` row** — the read leaves no trace the model can see, the human-side counterpart to membership-gated model reads (§operator-config, "the boundary is the client's"). It resolves against the connection's client loop so run-relative coordinates (`log:///<L>/<T>/<S>`) resolve correctly. Where `entry.read`/`log.read` leave no row but are scheme-limited, and `op.read` resolves everything but logs, `op.look` resolves everything **and** doesn't log. A non-READ statement is rejected. {§op-look}
1024
+ **`op.look` is the exception** to the "creates a turn, fires `log/entry`" rule above (§methods-op-mirror): it runs READ's full resolver (every scheme, full grammar — the client stays grammar-blind, forwarding its `<<LOOK>>` text with the op token swapped to `READ`) but mints **no turn and writes no `log_entries` row** — the read leaves no trace the model can see, the human-side counterpart to membership-gated model reads (§operator-config, "the boundary is the client's"). It resolves against the connection's client loop so worker-relative coordinates (`log:///<L>/<T>/<S>`) resolve correctly. Where `entry.read`/`log.read` leave no row but are scheme-limited, and `op.read` resolves everything but logs, `op.look` resolves everything **and** doesn't log. A non-READ statement is rejected. {§op-look}
1020
1025
 
1021
1026
  Future: `subscription.list`, `subscription.cancel` (the latter is `op.send({status: 499, recipient})` today).
1022
1027
 
@@ -1028,13 +1033,13 @@ Server-initiated events on the same WebSocket.
1028
1033
  |--------------------|-------------------------------------|------------|
1029
1034
  | `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§notifications-log-entry-notify} |
1030
1035
  | `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | Loop reaches terminal status. |
1031
- | `loop/proposal` | `{ logEntryId, sessionId, runId, loopId, turnId, op, target, body, attrs, flags }` | Dispatch pauses on status=202. Carries `flags` so server-YOLO clients can suppress review UI. Client responds with `loop.resolve` (or `PLURNK_SERVICE_PROPOSAL_TIMEOUT_MS` fires). |
1032
- | `session/created` | `{ id, name, projectRoot }` | Any client creates a session. |
1036
+ | `loop/proposal` | `{ logEntryId, workspaceId, workerId, loopId, turnId, op, target, body, attrs, flags }` | Dispatch pauses on status=202. Carries `flags` so server-YOLO clients can suppress review UI. Client responds with `loop.resolve` (or `PLURNK_SERVICE_PROPOSAL_TIMEOUT_MS` fires). |
1037
+ | `workspace/created` | `{ id, name, projectRoot }` | Any client creates a workspace. |
1033
1038
  | `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§notifications-stream-event-on-channel-change} |
1034
- | `stream/concluded` | `{ entryId, target, subscriptionId, scheme, closeStatus, summary, wakeAction, wakeLoopId? }` | A streaming subscription closed (subprocess finished / errored / cancelled). `wakeAction` says how the conclusion reached the run: `resumed-loop` (a slept `202` loop resumed in place, §run-lifecycle-wake-liveness), `no-op-active-loop` (folded into a live loop's next turn), `skipped-aborted`/`skipped-cancelled`/`skipped-no-provider`, or `no-loop` (nothing to resume). `summary` rides the notification for client display; it is no longer fed to the model as a prompt. {§notifications-stream-concluded} |
1039
+ | `stream/concluded` | `{ entryId, target, subscriptionId, scheme, closeStatus, summary, wakeAction, wakeLoopId? }` | A streaming subscription closed (subprocess finished / errored / cancelled). `wakeAction` says how the conclusion reached the worker: `resumed-loop` (a slept `202` loop resumed in place, §worker-lifecycle-wake-liveness), `no-op-active-loop` (folded into a live loop's next turn), `skipped-aborted`/`skipped-cancelled`/`skipped-no-provider`, or `no-loop` (nothing to resume). `summary` rides the notification for client display; it is no longer fed to the model as a prompt. {§notifications-stream-concluded} |
1035
1040
  | `telemetry/event` | `{ loopId, event: TelemetryEvent }` | A TelemetryEvent (parse error, engine-rail strike/cycle/sudden-death, scheme/provider failure) was buffered — the same envelope the model sees on the next packet, delivered live for client surfacing. {§notifications-telemetry-event} |
1036
1041
 
1037
- `stream/event` carries metadata only, never content. Clients fetch via `entry.read({target})`. **Every notification envelope carries its `sessionId`** (and `runId` where the emitter has it) so a multi-session client — one connection, many sessions — can route it ({§notifications-envelope-carries-sessionid}); the broadcast stays session-scoped too.
1042
+ `stream/event` carries metadata only, never content. Clients fetch via `entry.read({target})`. **Every notification envelope carries its `workspaceId`** (and `workerId` where the emitter has it) so a multi-workspace client — one connection, many workspaces — can route it ({§notifications-envelope-carries-workspaceid}); the broadcast stays workspace-scoped too.
1038
1043
 
1039
1044
  ### §connection-lifecycle Connection lifecycle
1040
1045
 
@@ -1047,9 +1052,9 @@ Server-initiated events on the same WebSocket.
1047
1052
  |-- discover() ---------------------------------->|
1048
1053
  |<------- { methods, notifications, capabilities }|
1049
1054
  | |
1050
- |-- session.attach(id=42) ------------------------>|
1051
- |<------- { id: 42, name: "demo-session" } |
1052
- | (daemon opens a client loop in session 42) |
1055
+ |-- workspace.attach(id=42) ------------------------>|
1056
+ |<------- { id: 42, name: "demo-workspace" } |
1057
+ | (daemon opens a client loop in workspace 42) |
1053
1058
  | |
1054
1059
  |-- loop.run(prompt="...") ----------------------->|
1055
1060
  |<-- notification: log/entry { ... } |
@@ -1062,12 +1067,12 @@ Server-initiated events on the same WebSocket.
1062
1067
  |<------- { status: 201 } |
1063
1068
  | |
1064
1069
  |-- ws.close ------------------------------------->|
1065
- | (daemon closes the client loop; session keeps)|
1070
+ | (daemon closes the client loop; workspace keeps)|
1066
1071
  ```
1067
1072
 
1068
- **The client's run.** A client connection is an actor (§machine-processes); its `op.*` write to its **own run** — `origin = "client"`, one loop per connection — and `log.read` reads that run. Disconnect closes the loop's status; rows persist. Multiple connections each get their own client run.
1073
+ **The client's run.** A client connection is an actor (§machine-processes); its `op.*` write to its **own worker** — `origin = "client"`, one loop per connection — and `log.read` reads that worker. Disconnect closes the loop's status; rows persist. Multiple connections each get their own client worker.
1069
1074
 
1070
- `loop.run` and `inject` target the **model's run** — a separate run holding the conversation, `origin = "model"`. Both runs share the session's one filesystem (§machine-processes); the packet renders only the model's run, so the client's ops are structurally absent from it — no origin filter (§actor-boundary-isolation). The model run (`Envelope.ensureModelRun`) and the connection's client run are distinct, each lazily allocated on first use — the §machine-processes conflation is corrected.
1075
+ `loop.run` and `inject` target the **model's worker** — a separate worker holding the conversation, `origin = "model"`. Both workers share the workspace's one filesystem (§machine-processes); the packet renders only the model's worker, so the client's ops are structurally absent from it — no origin filter (§actor-boundary-isolation). The model worker (`Envelope.ensureModelWorker`) and the connection's client worker are distinct, each lazily allocated on first use — the §machine-processes conflation is corrected.
1071
1076
 
1072
1077
  ### §errors Errors
1073
1078
 
@@ -1085,8 +1090,8 @@ Plurnk-specific (`-32000` to `-32099`):
1085
1090
 
1086
1091
  | Code | Meaning |
1087
1092
  |--------|----------------------------------------------------|
1088
- | -32000 | Not initialized (requires session attach) |
1089
- | -32001 | Session not found |
1093
+ | -32000 | Not initialized (requires workspace attach) |
1094
+ | -32001 | Workspace not found |
1090
1095
  | -32002 | Loop not found |
1091
1096
  | -32003 | Entry not found (engine 404) |
1092
1097
  | -32004 | Provider unavailable |
@@ -1109,7 +1114,7 @@ Each entry: question, answer, rationale, migration path.
1109
1114
 
1110
1115
  **Question.** Rummy uses priority-ordered filter chains for packet assembly. Plurnk builds a default ordered section list directly in `PacketBuilder.buildRequestPacket`, then lets trusted plugins rewrite it.
1111
1116
 
1112
- **Decision.** Two stages. (1) The engine builds the default section list. `slot` is a **trust boundary**: the system slot carries only framework-authored, non-injectable sections — `definition`, `tools`, `schemes`, the policy sections `system-policy`/`project-policy`, then the framework-status tail `errors` (uri+status pointers; the error item+body live in the log) and `git` (counts), with `budget` last (budget is law — a hard ceiling, the final word before the model acts). The user slot carries injectable content — `prompt` and `log` (READ results, exec output, the model's own mirror: data at the action point, never a privileged rule) — plus the `requirements` footer. Nothing that can carry attacker-reachable text rides the system slot. (2) `SchemeRegistry.transformSections` pipes that list through every registered scheme that implements `transformSections(sections) → sections`, in registration order, before the engine measures. A plugin returns whatever list it wants — add, remove, reorder. {§packet-plugin-transform}
1117
+ **Decision.** Two stages. (1) The engine builds the default section list. `slot` is a **trust boundary**: the system slot carries only framework-authored, non-injectable sections — `definition`, `tools`, `schemes`, the policy sections `system-policy`/`project-policy`, then the framework-status tail `child-streams`/`child-workers` (pointer lists), `errors` (uri+status pointers; the error item+body live in the log), `git` (counts), `budget` (budget is law — a hard ceiling), and the `prompt` pointer list at the very bottom (§prompt-auto-read). The user slot carries injectable content — `prompt` and `log` (READ results, exec output, the model's own mirror: data at the action point, never a privileged rule) — plus the `requirements` footer. Nothing that can carry attacker-reachable text rides the system slot. (2) `SchemeRegistry.transformSections` pipes that list through every registered scheme that implements `transformSections(sections) → sections`, in registration order, before the engine measures. A plugin returns whatever list it wants — add, remove, reorder. {§packet-plugin-transform}
1113
1118
 
1114
1119
  **Why a whole-list transform, not a per-section hook.** It is the legible, fork-avoiding seam: a plugin that can reshape the packet to its needs never has a reason to fork the engine (§ecosystem). And it is **strictly in-process and trusted** (behind `PLURNK_PLUGINS_TRUSTED_ONLY`) — the client/RPC wire never reaches the packet, because handing an untrusted connection the model's entire context is exactly the actor-boundary violation the engine exists to prevent. Pure list-in/list-out; no context is handed to plugins.
1115
1120
 
@@ -1129,16 +1134,17 @@ Each entry: question, answer, rationale, migration path.
1129
1134
  - **Provider tokens, stored at write.** `provider.countTokens` is the source of truth; `entry_channels.tokens` (via `_entry-crud`) and `log_entries.tokens` (via `Dispatcher.#writeLog`) are populated at write as a write-time snapshot. A `ceil(len/DIVISOR)` fallback (the divisor tripwire) applies only when no provider tokenizer is wired. {§tokenomics-tokens-stored-at-write}
1130
1135
  - **Render-weight budget.** The budget headline — `ceiling`, `tokenUsage`, `tokensFree` — is measured from the *assembled packet* (placeholders substituted after measuring), so it reflects what the model actually receives. A `SUM` of stored content-depth would mis-price the rendered packet; render-weight is the accurate measure. {§tokenomics-render-weight-budget}
1131
1136
  - **Per-turn weight.** A markdown table groups render-weight by turn — the `loop/turn` coordinate prefix — listed chronologically (oldest first). The turn is the grinder's fold unit, and the rail folds only the **newest** (§grinder); the model sees which turns are fat and can FOLD ahead of the rail. {§tokenomics-turn-totals}
1132
- - **Heaviest entries.** A second table lists the five heaviest log entries by render-weight, each by its `log:///<coord>/<op>` handle — the FOLD targets behind the turn weight. The handle carries the turn, so the two tables interlock. {§tokenomics-largest-entries}
1137
+ - **Heaviest entries.** A second table lists the five heaviest log entries by their **body price — the same number the row's own `tokens` shows** (what a FOLD saves; #466: the budget and the log never disagree about one row), each by its `log:///<coord>/<op>` handle — the FOLD targets behind the turn weight. A bodyless row is no FOLD target and never ranks. The handle carries the turn, so the two tables interlock. {§tokenomics-largest-entries}
1133
1138
  - **Context-window percent.** The headline carries usage as a percent of the ceiling — `usage Y (P%)` — a fullness gauge beside the absolutes. Reads the ceiling already in hand; no extra provider call. {§tokenomics-context-percent}
1134
- - **The window is a partition, never a fraction.** `effectiveWindow = min(PLURNK_SERVICE_CTX, provider.contextSize)` (CTX alone when the provider reports no window — but ONLY when that CTX is deliberate: an unpollable window riding BARE partition numbers fails hard at first use, naming the remediesnobody's numbers must never silently stand in for unknown physics {§tokenomics-window-unpollable-deliberate}) splits **without remainder**: `promptBudget = effectiveWindow − REASONING − ASSISTANT − SAFETY` is what the service may send, `max_tokens = REASONING + ASSISTANT` is the generation envelope passed on **every** `generate({maxTokens})` — no decode is unbounded — and `SAFETY` covers chat-template overhead no content counter sees. The prompt ceiling is **derived, never set**: a settable ceiling lets policy contradict physics, and a fractional one budgets the prompt against the window while *forgetting the response lives there too* (at 0.9 × 49152, any emission past ~4.9k overflowed gemma with a perfectly honest ruler). Reserves exceeding the window (`promptBudget ≤ 0`) are a configuration contradiction and fail hard. When native thinking is on, llama-server ignores per-request numeric budgets — the serving box's `--reasoning-budget` launch flag must equal `PLURNK_SERVICE_REASONING`, and the service warns at boot when it cannot verify that coupling. The LOCAL example partition (turboderp suffix, 78848 − 4096 − 8192 − 1024) yields exactly 65536 prompt tokens; the bare cloud defaults yield a large decode envelope the backend self-clamps. {§tokenomics-window-partition} **The partition is PER ALIAS** (#352): each knob resolves `PLURNK_SERVICE_<KNOB>_<alias>` over the bare fallback (providers' `scopeEnvToAlias`), scoped by the alias that produced the turn's provider (the service's own provider→alias side-table, falling back to the active boot alias). BARE defaults are cloud-generous — the generation envelope is a large `max_tokens` the backend self-clamps to its true output limit (cloud APIs accept and silently clamp; none expose a max-output field, so generous-and-clamp is the honest posture, and a cloud reasoner burning ~16k on thought finishes its DSL instead of dying at `finish=length`). LOCAL (llama-server) aliases MUST keep a measured envelope — `n_predict` is honored to the context wall, so an unbounded decode wall-runs (providers#10, the cap doctrine's origin); a local alias whose partition contradicts its real window fails HARD at build time, naming the per-alias knobs to set.
1135
- - **Derivation is off the hot path; search is never degraded by it.** The per-turn derivation pump and the session warm run on a background chain (serialized, drained at daemon stop, failures logged) — **a turn never waits on an embedding** (a 2-CPU container CPU-embedding a 335-entry ingest starved every loop ~28min). Full fidelity survives the move on both fusion halves: the **keyword half indexes at the write** (plain string→FTS, no handler invoked — a cold session's first query narrows over everything ever written) {§semantic-fts-at-write}, and a `~` query **derives its own FTS-narrowed candidate slice inline** at dispatch (bounded, cap-telemetered) — ranking only ever scores the narrowed set, so warming exactly that slice is bit-identical to a fully-warm corpus, from turn 1, on any hardware {§semantic-cold-query-full-fidelity}. {§derivation-off-hot-path}
1139
+ - **The window is a partition, never a fraction.** `effectiveWindow = min(PLURNK_SERVICE_CONTEXT_WINDOW, provider.contextSize)` (CONTEXT_WINDOW alone when the provider reports no window — but ONLY when that CONTEXT_WINDOW is deliberate: an unpollable window riding BARE partition numbers is nobody's policy, so it is treated as **NO-CAP** — the prompt is unbounded, the budget and ceiling resolve to null and the gauge omits its headline rather than let numbers nobody chose silently stand in for unknown physics; a probe blip must degrade to no-cap, never crash the loop {§tokenomics-window-unpollable-deliberate}) splits **without remainder**: `promptBudget = effectiveWindow − REASONING − COMPLETION − SAFETY` is what the service may send, `max_tokens = REASONING + COMPLETION` is the generation envelope passed on **every** `generate({maxTokens})` — no decode is unbounded — and `SAFETY` covers chat-template overhead no content counter sees. The prompt ceiling is **derived, never set**: a settable ceiling lets policy contradict physics, and a fractional one budgets the prompt against the window while *forgetting the response lives there too* (at 0.9 × 49152, any emission past ~4.9k overflowed gemma with a perfectly honest ruler). Reserves exceeding the window (`promptBudget ≤ 0`) are a configuration contradiction and fail hard. When native thinking is on, llama-server ignores per-request numeric budgets — the serving box's `--reasoning-budget` launch flag must equal `PLURNK_SERVICE_REASONING`, and the service warns at boot when it cannot verify that coupling. The LOCAL example partition (turboderp suffix, 78848 − 4096 − 8192 − 1024) yields exactly 65536 prompt tokens; the bare cloud defaults yield a large decode envelope the backend self-clamps. {§tokenomics-window-partition} **The partition is PER ALIAS** (#352): each knob resolves `PLURNK_SERVICE_<KNOB>_<alias>` over the bare fallback (providers' `scopeEnvToAlias`), scoped by the alias that produced the turn's provider (the service's own provider→alias side-table, falling back to the active boot alias). BARE defaults are cloud-generous — the generation envelope is a large `max_tokens` the backend self-clamps to its true output limit (cloud APIs accept and silently clamp; none expose a max-output field, so generous-and-clamp is the honest posture, and a cloud reasoner burning ~16k on thought finishes its DSL instead of dying at `finish=length`). LOCAL (llama-server) aliases MUST keep a measured envelope — `n_predict` is honored to the context wall, so an unbounded decode wall-runs (providers#10, the cap doctrine's origin); a local alias whose partition contradicts its real window fails HARD at build time, naming the per-alias knobs to set.
1140
+ - **Derivation is off the hot path; search is never degraded by it.** The per-turn derivation pump and the workspace warm run on a background chain (serialized, drained at daemon stop, failures logged) — **a turn never waits on an embedding** (a 2-CPU container CPU-embedding a 335-entry ingest starved every loop ~28min). Full fidelity survives the move on both fusion halves: the **keyword half indexes at the write** (plain string→FTS, no handler invoked — a cold workspace's first query narrows over everything ever written) {§semantic-fts-at-write}, and a `~` query **derives its own FTS-narrowed candidate slice inline** at dispatch (bounded, cap-telemetered) — ranking only ever scores the narrowed set, so warming exactly that slice is bit-identical to a fully-warm corpus, from turn 1, on any hardware {§semantic-cold-query-full-fidelity}. {§derivation-off-hot-path}
1136
1141
  - **Binary truth beats the label; no entry dominates the corpus.** A tracked member whose HEAD bytes contain NUL is materialized as a binary marker (empty body, `application/octet-stream`, READ-415) **regardless of what extension-based detection claims** — the markdown default for unmapped extensions once shipped a 3.3MB `.wasm` blob into the semantic corpus as prose, three copies, ~10M tokens (#320) {§membership-binary-sniff}. And the per-entry chunk cap is a **latency stage, never a coverage bound**: the inline (dispatch-time) slice embeds head-first up to its budget so a cold `~` answers in bounded seconds — and a capped pass does NOT stamp the deep hash, so the background pump completes the entry to full depth (a 300-page book is entirely searchable at steady state; rank cannot be dominated regardless — `semantic_rank` takes one best chunk per entry). A flat cap would silently foreclose legitimate large texts: head-only vectors under a whole-file keyword narrow return head-biased spans, permanently. {§semantic-entry-chunk-cap}
1137
1142
  - **A turn is never blank; the provider never adjudicates.** A completed exchange ALWAYS returns from the provider — the model's bytes flow in `assistant` no matter what, with any grammar-conformance verdict riding `response.telemetry` as an **observation** (providers 0.32; the provider transports and observes — the engine's own parse is the judge). Every emission takes the one pipeline: complete statements dispatch, malformed text mints position-carrying parse-error rows the model reads next turn, nothing-parsed is the ordinary no-ops 422, and the record keeps the emission verbatim with its real usage billed. A `ProviderError` reaching the engine means NO completed exchange exists (auth, network beyond retries, rate limit) — an infrastructure failure, and the loop dies 500 carrying the cause, first occurrence: tolerating an infrastructure violation is how a bug accretes concentric layers of policy instead of a fix (the retired empty-turn fallback laundered provider adjudications into model-behavior 422s, and forensics chased the wrong suspect for days). {§turn-never-blank}
1138
- - **The ceiling calibrates to usage.** `countTokens` may be a heuristic ruler (the openai family defaults to chars/4; escaped-JSON log rows run ~2.7 real chars/tokenhonest arithmetic on that ruler shipped a 65k-real packet into a 49k window, #311). Every response carries ground truth: `usage.prompt` counts the whole wire request. The engine keeps each loop's observed real/measured ratio (monotone maxthe worst-observed packing wins) and the effective ceiling is `ceiling / ratio`, so past a loop's first response a real context overflow is unreachable; turn 1 rides the floor-sized packet's natural headroom. The floor is **exact-only**: an exact ruler never expands past 1 (calibration only tightens), while a certified upper-bound ruler (no exact tokenizer`gauge.exact === false`) calibrates to observed truth in BOTH directionsit overmeasures by construction, so expansion toward `usage.prompt` ground truth cannot overshoot the window, and refusing it silently halves the model's room (run24: a 256k grok strangled at ~35k real, six grinder fires, struck out without ever editing a file). A provider failure that still escapes lands as a loop-terminal **500 carrying the cause** on both the row and the `loop/terminated` broadcast — never a contentless 500 over a still-102 row. {§tokenomics-ceiling-calibrates-to-usage}
1139
- - **Curation pressure gates on occupancy.** The budget section's Turns/Heaviest tables — a standing FOLD-target list — render only at **50%+ occupancy** (assembled total / ceiling); below that the headline's numbers stand alone. A high-headroom model reads the tables as a todo and burns turns on token hygiene at 3–25% occupancy (#308, the bench grok run); a null ceiling can't calibrate, so the full readout stays. The requirements footer carries only the overflow-RECOVERY must, never ambient optimize-your-context pressure. {§tokenomics-pressure-gates-on-occupancy}
1143
+ - **One model-agnostic ruler; exact only at the materialization gate.** The daemon runs many workers on DIFFERENT models in ONE workspace concurrently (per-loop model selection, #414), and token accounting is workspace-wide (the catalog lists every workspace entry). A single per-model "true" count is therefore a fiction there is no one model to be true to and exact tokenizers exist only for models with a bundled `tokenizer.json` (most frontier models degrade to the chars ruler anyway). So the ENTIRE model-facing perspective catalog per-entry costs, `tokensFree`, ceiling, per-turn weights, write-time stamps uses ONE model-INDEPENDENT ruler: `rulerCount = ceil(chars/2)` (mimetypes' no-exact heuristic). One number per content, identical no matter which model reads it; zero per-model state; trivially concurrent-safe. It is a conservative UPPER bound, so the model's whole ledger is coherent AND safe: comparing ruler-weight to the real-token ceiling (window reserves, no calibration ratio) is itself the conservative biasthe model curates against less room than it has and never overflows for typical content, at the cost of some window under-utilization (accepted). The provider's EXACT count is used at exactly ONE point: the packet-materialization fit-gate (`exactPacketTokens` provider.countTokens of the assembled packet, once per turn), which guards the pathological tail where the ruler under-counts token-dense content. The prior per-alias usage-calibration ratio and the (content_hash, tokenizer_id) per-tokenizer cache were RETIRED (owner ruling 2026-07-13): they delivered per-model exactness the real mixed-model topology mostly can't provide, at the cost of workspace-wide concurrent recount passes latent-bug surface that reads as model weakness on benchmarks. A provider failure that still escapes lands as a loop-terminal **500 carrying the cause** on both the row and the `loop/terminated` broadcast. {§tokenomics-agnostic-ruler}
1144
+ - **Curation pressure gates on occupancy.** The budget section's Turns/Heaviest tables — a standing FOLD-target list — render only at **50%+ occupancy** (assembled total / ceiling); below that the headline's numbers stand alone. A high-headroom model reads the tables as a todo and burns turns on token hygiene at 3–25% occupancy (#308, the bench grok run); a null ceiling (an unbounded window, §tokenomics-window-unpollable-deliberate) can't calibrate occupancy, so the headline is omitted and the tables ship un-truncated. The requirements footer carries only the overflow-RECOVERY must, never ambient optimize-your-context pressure. {§tokenomics-pressure-gates-on-occupancy}
1145
+ - **The Budget renders as visual mermaid.** {§budget-mermaid} By default (`PLURNK_SERVICE_BUDGET_MERMAID`; set `off` to A/B against the tabular baseline, #440's before/after), the Budget section keeps its ceiling/usage/free headline (weighability) and REPLACES the **Turns** table with two budget-scaled mermaid diagrams — turn-composition `treemap-beta` (turn boxes + `system + context` + `free` compose the whole ceiling, the per-turn FOLD surface labeled `turn L/T`), and a used-vs-free `pie` gauge (which doubles as a training exemplar for the model's own user-facing SENDs). The **Heaviest-items** list stays a plain ranked table (`log:///` handle + tokens) — a ranking is not a composition, so it is never a chart, and two mermaid diagrams are enough visual examples (#450). Both diagrams scaled to the CEILING, so salience tracks pressure: `free` dominates when calm, the turn boxes fill toward urgent as the ceiling nears — so unlike the tabular Turns/Heaviest pair a mermaid budget is NEVER <50%-truncated (the calm low-usage view is the point). `free`/`used`/`system+context` resolve post-assembly like the headline figures. Syntax validated to render on GitHub (plurnk-plurnkdown/demo/budget-mermaid.md). #440
1140
1146
  - **Depth re-counted at render.** The manifest re-tokenizes each entry's `tokens` through the live provider at build — never the write-time snapshot — so a model change between loops can't stale the catalog. Every token figure in the packet is render-fresh, manifest and budget alike; nothing trusts a cross-loop cached total.
1141
- - **Derived token counts are keyed on (content_hash, tokenizer_id)** {§tokenomics-derived-token-counts} the deep_hash discipline applied to the gauge (#312): one content blob, N tokenizer-identity-specific counts, computed once each by the mimetypes Tokenizers seam (the identity is the tokenizer.json sha — NEVER the model id, so vocab-sharing models swap with zero recounts while a real tokenizer change recounts all, lazily via the pump's warm pass on the shared embeddings progress channel). Static channel writes stamp `content_hash`; catalog reads COALESCE the keyed count over the write-time stamp for the ACTIVE turn's gauge (threaded per turn, never engine state concurrent loops on different providers each read their own honest numbers). When the seam resolves nothing exact, the provider's chars/2 upper bound is used and SURFACED (`tokenizer_unavailable`, once per model) an error-class signal, never a silent number. The assembled-packet ruler remains the provider count corrected by the per-loop usage calibration (§tokenomics-ceiling-calibrates-to-usage) — ground truth anchors the ceiling regardless of ruler.
1147
+ - **Content identity, not per-tokenizer counts.** {§tokenomics-content-hash-identity} Static channel writes stamp `content_hash` (sha256) as a stable per-content identity. The per-tokenizer token cache it once keyed (content_hash, tokenizer_id) was RETIRED with the agnostic ruler (§tokenomics-agnostic-ruler): there is one ruler, computed inline at render (`ceil(chars/2)`), so there is nothing to key by model and no warm-pass recount. content_hash remains for content identity; the token count is never stored per-model.
1142
1148
  - **The delivered packet is never over budget.** The readout shows the state of the packet the model actually has, and the grinder (§grinder) folds any over-ceiling packet back under *before* it is sent — so a delivered budget headline is always usage ≤ ceiling, percent ≤ 100, free ≥ 0. The percent is of the **post-fold** packet; the pre-fold overshoot is engine trivia the model never sees. A packet that can't be folded under even after the grinder folds the newest turn boundary (§grinder-layer1-rollback) is the corner case: the loop **hard-413s** rather than deliver an over-budget packet — the engine NEVER reaches back to fold older turns to save a loop (that would make the engine the janitor of the model's memory and collapse the entire narrative); a model that won't self-curate strikes out instead. Its STORED failure record renders the overshoot honestly — `free` floors at 0 (never negative), the percent passes 100 — never clamped to hide the degenerate state, but never the model's reasoning surface either. {§tokenomics-over-budget-floor}
1143
1149
 
1144
1150
  **Rejected / obviated.**
@@ -1152,46 +1158,50 @@ Each entry: question, answer, rationale, migration path.
1152
1158
 
1153
1159
  ### §membership Workspace identity, membership, disk co-location
1154
1160
 
1155
- **Question.** How does plurnk represent the project a session works on? Where does file membership come from? Does writing an entry imply writing to disk?
1161
+ **Question.** How does plurnk represent the project a workspace works on? Where does file membership come from? Does writing an entry imply writing to disk?
1156
1162
 
1157
1163
  **The boundary is the client's.** The client owns the model's filesystem access in both directions: reads are membership-gated (a file is invisible to the model unless it is a member), and writes are proposals the client accepts or rejects (`yolo` auto-accepts). Writing an entry never implies writing to disk — entries are canonical in the store; disk only moves when the client accepts a side-effecting proposal, and only where `project_root` is set (null = headless, client owns materialization).
1158
1164
 
1159
- **Tier — session is the world; permissions are the session's.** Membership, the overlay, and the git flags are **session-tier** (`session_constraints.session_id`, service/session config) — never per-run. Every run in a session shares one world (§machine-processes: one filesystem, one overlay); a run is a *log* — a perspective over that world — owning no membership of its own. A declaration reshapes the one world for every run, never per-connection. `runs.origin` is attribution (whose perspective), not a permission.
1165
+ **Tier — workspace is the world; permissions are the workspace's.** Membership, the overlay, and the git flags are **workspace-tier** (`workspace_constraints.workspace_id`, service/workspace config) — never per-worker. Every worker in a workspace shares one world (§machine-processes: one filesystem, one overlay); a worker is a *log* — a perspective over that world — owning no membership of its own. A declaration reshapes the one world for every worker, never per-connection. `workers.origin` is attribution (whose perspective), not a permission.
1160
1166
 
1161
- **Workspace identity.** No `projects` table; `sessions.project_root TEXT` (nullable = headless) anchors the workspace. `entries.scope ∈ {'session','run'}` (agent-scope retired). Workspace = session; no users/auth/multi-tenant.
1167
+ **Workspace identity.** No `projects` table; `workspaces.project_root TEXT` (nullable = headless) anchors the workspace. `entries.scope ∈ {'workspace','worker'}` (agent-scope retired). Workspace = workspace; no users/auth/multi-tenant.
1162
1168
 
1163
- **Search declares web members.** {§search-prefetch} A search runtime's `#results` digest is a CANDIDATE enumeration; on the exec's conclusion inside the `PLURNK_SERVICE_EXEC_HOLD` window, so the model's next packet already carries the outcome the engine materializes every candidate URL into an ordinary http entry through the scheme's own read path (a sync pass, like the workspace pass: below the dispatcher, minting NO log rows; the pass's page conclusions are swallowed, never wake edges), then REWRITES `#results` as the survivor render. The entry set is the only truth: a page that fails to open, times out (`PLURNK_SERVICE_SEARCH_PREFETCH_TIMEOUT_MS`), or renders to nothing never becomes an entry, so a dead row never appears nothing is filtered because nothing else is authoritative (owner ruling). Chooser context (title/snippet/publishedDate) is stamped onto each survivor's entry attributes and rides the render together with live token/line costs — ONE listing, no synthetic FIND (owner ruling). A prefetched page is INDISTINGUISHABLE from a hand-READ page: same channels, same validators, same catalog row, same scheme rules — there are no magic entries. Fan-out width is `PLURNK_SERVICE_SEARCH_PREFETCH_CONCURRENCY`; the participating runtimes are `PLURNK_SERVICE_SEARCH_PREFETCH_RUNTIMES`. **The search gates** (#406, owner ruling) are rail-family accounting — in-memory per-loop state cleaned at the same seam as strikes, restart-drop accepted (a post-restart duplicate re-fetches; the TTL makes it cheap): an IDENTICAL duplicate (same runtime + command in one loop) **strikes and serves** — status 409 (the strike rail counts the turn failure) carrying the prior survivor digest re-read live from the original exec entry, no re-fetch, no provenance prose; the per-turn CAP (`PLURNK_SERVICE_SEARCH_MAX_PER_TURN`) is flood control — 429 with a legible steer, nothing served. {§search-gate}
1169
+ **Search declares web members.** A search runtime enumerates candidate URLs and hands each to the engine as a `content: null` `entry()` request (§exec-entry-sink): the guarded `WebFetcher` sink fetches every candidatein parallel, off the write-serialization chain materializes the live ones as ordinary http entries and rejects the dead, so the executor writes `#results` as survivors only (chooser context: title/url/snippet/publishedDate). Costs never ride the digest FIND over the tagged set is the real-cost listing, its body-less catalog rows carrying `{mimetype, tokens, lines}` natively. Because the search family is in `PLURNK_SERVICE_EXEC_HOLD` the cycle holds until the stream concludes (§exec-hold-until-concluded), and the executor awaits every `entry()` before it concludes so the digest the model's next packet carries is post-fetch truth, dead rows already gone, with no separate pass. A materialized page is INDISTINGUISHABLE from a hand-READ page: same channels, same validators, same catalog row — there are no magic entries. **The search gates** (#406, owner ruling) are rail-family accounting — in-memory per-loop state cleaned at the same seam as strikes, restart-drop accepted (a post-restart duplicate re-fetches; the TTL makes it cheap): an IDENTICAL duplicate (same runtime + command in one loop) **strikes and serves** — status 409 (the strike rail counts the turn failure) carrying the prior survivor digest re-read live from the original exec entry, no re-fetch, no provenance prose; the per-turn CAP (`PLURNK_SERVICE_SEARCH_MAX_PER_TURN`) is flood control — 429 with a legible steer, nothing served. {§search-gate}
1164
1170
 
1165
- **git is the substrate.** {§membership-git-membership} git-tracked files (`git ls-files`) are members with no explicit overlay — channel-less markers, disk is truth. git absent → no fs-walk (non-git/headless get no substrate membership); `pick` is then the sole source. **Every git spawn scrubs ambient `GIT_*`** (#401): a process launched from a git hook inherits `GIT_DIR` — absolute in a worktree checkout — which retargets every child git at the enclosing repo regardless of cwd; project git state binds to the session's `project_root`, never to whoever spawned the daemon. One boundary (`git-env.hermeticGitEnv`), threaded through membership, telemetry, and every test fixture. {§membership-git-hermetic}
1171
+ **git is the substrate.** {§membership-git-membership} git-tracked files (`git ls-files` semantics) are members with no explicit overlay — channel-less markers, disk is truth. git absent → no fs-walk (non-git/headless get no substrate membership); `pick` is then the sole source. **Every core git read is in-process by default** {§git-portable-default}: isomorphic-git (exact-pinned), an explicit-`dir` library read — no subprocess, no ambient `GIT_*`, no global/system config — so the read is hermetic **by construction** and identical on a sandboxed or git-less host; linked worktrees and submodule boundaries (gitlinks filtered) behave as native. The untracked scan is a pruning ignore-walk, **differential-gated**: it must reproduce native `ls-files --others --exclude-standard` across a gitignore edge-case corpus (negations, anchors, nested `.gitignore` precedence, `.git/info/exclude`, dir-vs-glob patterns, embedded-repo boundaries) or it does not ship. `PLURNK_SERVICE_GIT_NATIVE=1` routes membership + status to the system git binary {§git-native-flag} — in-process membership measures ~8× native (~130ms at 20k files) and the status read ~55× (statusMatrix hashes the workdir), so a large-repo host with git installed buys the hot path back. **That native arm scrubs ambient `GIT_*`** (#401): a process launched from a git hook inherits `GIT_DIR` — absolute in a worktree checkout — which retargets every child git at the enclosing repo regardless of cwd; project git state binds to the workspace's `project_root`, never to whoever spawned the daemon. One boundary (`git-env.hermeticGitEnv`), threaded through the native arm and every shell-git test fixture. {§membership-git-hermetic}
1166
1172
 
1167
- **Membership is a declared forest of repos.** {§membership-forest} A workspace is not one git repo but a **forest**: membership is the union, over a session-declared set of repos, of each repo's `ls-files` (gitlinks/mode-160000 filtered), each path-prefixed by the repo's path relative to `project_root`. The root need not itself be a repo — a non-git parent of ninety repos resolves to all ninety. A worktree, a submodule, a buried repo are not special cases: each is just another declared repo, resolved `rev-parse --show-toplevel` → `ls-files` in the tree it points at.
1173
+ **Membership is a declared forest of repos.** {§membership-forest} A workspace is not one git repo but a **forest**: membership is the union, over a workspace-declared set of repos, of each repo's `ls-files` (gitlinks/mode-160000 filtered), each path-prefixed by the repo's path relative to `project_root`. The root need not itself be a repo — a non-git parent of ninety repos resolves to all ninety. A worktree, a submodule, a buried repo are not special cases: each is just another declared repo, resolved `rev-parse --show-toplevel` → `ls-files` in the tree it points at.
1168
1174
  - **Membership-gated edits.** {§membership-edit-membership-gate} EDIT is bounded by membership exactly as READ is. An existing **member**'s baseline is its entry snapshot — the body channel the model READ, not a fresh disk read — so the diff is naive against the view the model saw, never empty (the write-side CAS, §membership-edit-write-cas, prevents the silent overwrite of out-of-band drift). An existing **non-member** is refused (403) *before* any read or write: the model never reads a file it can't see (no leak into the proposal) and never overwrites one (no wiping a gitignored `.env` it never added). A **new path** stays open — proposal→accept adds it to the manifest. Reaching past membership is `EXEC[sh]`'s job, not the file scheme's.
1169
1175
 
1170
- **The overlay — `pick | view | hide | repo`, removed by `drop`.** A `session_constraints` table (effect ∈ {pick, view, hide, repo}, target) is the client's supersede over git; `drop` removes any declaration. Resolved membership is `(⋃ repo ls-files ∪ pick) − hide`, with `view` enforced at the edit gate.
1176
+ **The overlay — `pick | view | hide | repo`, removed by `drop`.** A `workspace_constraints` table (effect ∈ {pick, view, hide, repo}, target) is the client's supersede over git; `drop` removes any declaration. Resolved membership is `(⋃ repo ls-files ∪ pick) − hide`, with `view` enforced at the edit gate.
1171
1177
  - **`repo`** {§membership-overlay-repo} — declare a git repo (a folder) ANYWHERE — under the project root or outside it. Its `ls-files` (+ untracked-non-ignored, §membership-auto-add) join membership, addressed **relative to the project root** always — a clean path when the repo is under it, a `..`-prefixed one when it's outside (the universal `join(root, pathname)` disk-resolver collapses it back to disk; an absolute key would nest under root and never materialize). `project_root` carries no boundary, it is only the relative-address base. Submodules/nested repos are separate `repo` declarations — no recursion; the client owns the scan and the trust call.
1172
1178
  - **Auto-add** {§membership-auto-add} — a declared repo's membership is its tracked `ls-files` PLUS its untracked-but-not-ignored files (`git ls-files --others --exclude-standard`), 'git' origin. A model-created file is a member the moment it exists — no `git add` — while `.gitignore` still filters it, and deleting it un-registers it like any git member. The model's new files surface in the catalog and the EMI without a distinct "add" op.
1173
1179
  - **`pick`** {§membership-overlay-pick} — admit an untracked file git misses: a targeted client-dictated `node:fs` glob scan over untracked matches (files only), 'constraint' origin, reconciled like git members. Enumerated, so the manifest stays exhaustive. git-absent, `pick` is the *sole* membership source.
1174
1180
  - **`hide`** {§membership-overlay-hide} — exclude a tracked file: resolution drops matches (`node:path.matchesGlob`) and reconciles so the entry set *equals* the member set. The lever to exclude a committed-but-sensitive tracked file; `entries.membership_origin` keeps reconciliation off model-created members.
1175
1181
  - **`view`** {§membership-overlay-view} — keep a member readable but refuse `File.edit`, 403'd at the membership check before any diff. (Admitting an untracked file as `view` rides on `pick`'s scan.)
1176
- - **Resolved effect is a read, not a re-derivation.** {§membership-resolved-effects} `session.members` surfaces each candidate's resolved effect — `(ls-files ∪ pick) − hide` tagged `member` / `view`, plus the `hide`-excluded `hidden` set — so a client signs file visibility (member / read-only / ignored) without reimplementing the overlay glob-matching. The daemon owns git + the globs; the per-file effect is its to resolve, the client's to render.
1182
+ - **Resolved effect is a read, not a re-derivation.** {§membership-resolved-effects} `workspace.members` surfaces each candidate's resolved effect — `(ls-files ∪ pick) − hide` tagged `member` / `view`, plus the `hide`-excluded `hidden` set — so a client signs file visibility (member / read-only / ignored) without reimplementing the overlay glob-matching. The daemon owns git + the globs; the per-file effect is its to resolve, the client's to render.
1177
1183
 
1178
1184
  **File ops act on the entry, not the disk; the two reconcile only at gates.** A `file:///` member is a row whose body channel holds the *materialized snapshot* of its disk content. READ returns that channel; EDIT diffs against it — neither reaches the filesystem directly. Entry and disk reconcile at exactly two gates: the **pre-turn materialize** (disk → entry, below) and the **accept-time write-back** (entry → disk, §proposal). Between the gates the entry is the truth the model curates against, and `synced_sig` — the member's last-synced disk stat (`mtime:size`) — is the version token both gates compare on.
1179
1185
 
1186
+ **The pump dedups then parallelizes.** {§derivation-dedup-parallel} The per-turn derivation pump groups the changed-entry worklist by `content_hash` and derives each UNIQUE content once (the reuse below applies its vectors to the rest), then runs those unique reps with bounded concurrency (`PLURNK_SERVICE_DERIVE_CONCURRENCY`, default 1) so their embeds OVERLAP and saturate the embedder's data-parallel worker pool — a large-workspace ingest goes from a single-core minutes-long grind (sequential per-entry `await`, one starving worker) to a fast multi-core pass (#416; the pool itself was never the bottleneck — mimetypes#420 — the sequential call granularity was). Grouping first is what keeps the dedup: identical siblings are adjacent after the smallest-first sort, so a naive concurrent loop would run them together and double-embed; the rep-then-dups split embeds once regardless of concurrency. Scheduling only — every pending entry derives to full depth exactly once, identical at concurrency 1 and N.
1187
+
1188
+ **Identical content embeds once.** {§semantic-embed-dedup} Before an entry's (expensive, in-process WASM) embed, the pump reuses an existing embedding for IDENTICAL body content — any other entry, same active model, matched by the stamped `content_hash`: the metaproject's 15× duplicated `tokenizer.json` (one per worktree/leaf) embeds ONCE and the rest copy the vectors. Per-model (a mismatched `embedding_model` never cross-copies — dimensions can differ); on a hit the deep_hash stamps a full derivation and the embed is skipped entirely. Cuts the WORK; the parallelism of what remains is the embedder's (mimetypes-embeddings, #416).
1189
+
1180
1190
  **Sync is idempotent and change-gated.** {§membership-change-gated-sync} Per turn, membership materializes every member's disk content into its entry — but the *work* is gated on a cheap per-member change-detect: a member unchanged on disk since its last sync is not re-read, re-tokenized, or rewritten. **Coverage is exhaustive — every member is detected every turn — but work is proportional to change**, so a ninety-repo forest costs detection, not a full re-read. Invariant: after a pass every member's entry equals its disk content; a no-change pass is a no-op.
1181
1191
 
1182
1192
  **EMI divergence signal.** {§membership-emi-divergence-signal} The detector that gates the work *is* the one that fires this — one mechanism, not a second full read. When the change-detect finds a member moved out-of-band, the delta detector (§env-delta) surfaces it as a system `EDIT` log row naming the file, `source="file"` — the model sees what changed without diffing the manifest against memory. The model's own edits are write-through (the entry equals disk after a File write), so the scan never mis-attributes them as external divergence.
1183
1193
 
1184
- **The write-back is a compare-and-swap — never a clobber, never a clever merge.** {§membership-edit-write-cas} EDIT is *naive against the snapshot*: it diffs the model's change onto the entry's body channel — the exact bytes the model READ — and the proposal carries the `synced_sig` that snapshot was taken at. At accept, `applyResolution` re-stats disk and lands the proposed content only if that signature still matches. If disk moved out-of-band in the propose→accept window — a sibling run, the user's editor, a build step — the write is **refused** with a `write_conflict` and **nothing is written**. The engine neither blind-writes over the ambient change (a *clobber*) nor silently re-diffs the model's edit against a state it never saw (getting *clever*) — both would bury a stale-view contract violation under a fallback. The conflict surfaces instead: a ≥400 apply downgrades to a reject (§proposal), so the model sees the EDIT **did not occur** (400; the `write_conflict` outcome is forensics-only), the next reconcile narrates the real disk content as a `source=file` divergence (§membership-emi-divergence-signal), and the model re-reads and re-proposes against the fresh snapshot.
1194
+ **The write-back is a compare-and-swap — never a clobber, never a clever merge.** {§membership-edit-write-cas} EDIT is *naive against the snapshot*: it diffs the model's change onto the entry's body channel — the exact bytes the model READ — and the proposal carries the `synced_sig` that snapshot was taken at. At accept, `applyResolution` re-stats disk and lands the proposed content only if that signature still matches. If disk moved out-of-band in the propose→accept window — a sibling worker, the user's editor, a build step — the write is **refused** with a `write_conflict` and **nothing is written**. The engine neither blind-writes over the ambient change (a *clobber*) nor silently re-diffs the model's edit against a state it never saw (getting *clever*) — both would bury a stale-view contract violation under a fallback. The conflict surfaces instead: a ≥400 apply downgrades to a reject (§proposal), so the model sees the EDIT **did not occur** (400; the `write_conflict` outcome is forensics-only), the next reconcile narrates the real disk content as a `source=file` divergence (§membership-emi-divergence-signal), and the model re-reads and re-proposes against the fresh snapshot.
1185
1195
 
1186
- The version travels *with the proposal*, never re-read from the entry at accept: a sibling run in the same session may reconcile while this proposal sits paused, advancing the entry's `synced_sig` to the drifted disk — comparing against the *current* entry sig would wave that clobber through, so the comparison is always against the sig the proposal was computed at. A proposal that assumed an **absent** path (a create) conflicts only if a file has since appeared; a member with **no recorded snapshot** (an un-materialized entry, null `synced_sig`) has no baseline to guard and writes through — the two are told apart by the proposal's `existed` flag, not by a null sig alone. On a clean landing the entry refreshes to the written content and `synced_sig` is **restamped** to it, so the next reconcile recognizes the model's own write (not an external divergence) and a second same-turn edit bases on the landed bytes, not a stale sig. This is the write-side twin of the read-side change-gate (§membership-change-gated-sync): one `synced_sig`, gating both the re-read and the write.
1196
+ The version travels *with the proposal*, never re-read from the entry at accept: a sibling worker in the same workspace may reconcile while this proposal sits paused, advancing the entry's `synced_sig` to the drifted disk — comparing against the *current* entry sig would wave that clobber through, so the comparison is always against the sig the proposal was computed at. A proposal that assumed an **absent** path (a create) conflicts only if a file has since appeared; a member with **no recorded snapshot** (an un-materialized entry, null `synced_sig`) has no baseline to guard and writes through — the two are told apart by the proposal's `existed` flag, not by a null sig alone. On a clean landing the entry refreshes to the written content and `synced_sig` is **restamped** to it, so the next reconcile recognizes the model's own write (not an external divergence) and a second same-turn edit bases on the landed bytes, not a stale sig. This is the write-side twin of the read-side change-gate (§membership-change-gated-sync): one `synced_sig`, gating both the re-read and the write.
1187
1197
 
1188
1198
  The CAS is the **hard backstop**, at the moment of writing, on every accept path. It composes with — and is distinct from — the YOLO-only `staleClobberRisk` guard (§dual-yolo-stale-clobber-reject): that guard refuses to *auto-accept* an edit whose target already diverged earlier this turn (the read→propose window, server-YOLO path only); the CAS refuses to *write* against a snapshot disk has left (the propose→write window, every path). Together they bracket the full read→write span.
1189
1199
 
1190
1200
  **Permission flags.** {§membership-git-flags} `PLURNK_SERVICE_GIT_ALLOWED` is the hard ceiling: `=0` denies all git membership service-wide, un-re-enableable — the sandbox/benchmark lockout. `PLURNK_SERVICE_GIT_AUTO` is the default declaration: `=1` (default) declares an implicit `repo` at `project_root` (no-op if it isn't a git tree); `=0` declares nothing — service/clients `repo`-declare explicitly. `ALLOWED` gates `AUTO`.
1191
1201
 
1192
- **Rationale.** Session is the right scope unit; membership *is* the curation, outsourced and tiered: git bounds it by tracking, the client supersedes by overlay, the model curates its own render by READ/FOLD — the engine curates nothing. The forest falls out of "session = world": one workspace can be many repos, so membership is their union, declared not guessed (the scan and its security are the client's). Exhaustiveness is a property of *coverage*, not *work*: every member is checked every turn so no drift hides, but unchanged members cost only a detect — the full-repo cost is git's to bound (what it tracks) and the client's to bound (`hide`), never the engine's to pay re-reading what hasn't moved.
1202
+ **Rationale.** Workspace is the right scope unit; membership *is* the curation, outsourced and tiered: git bounds it by tracking, the client supersedes by overlay, the model curates its own render by READ/FOLD — the engine curates nothing. The forest falls out of "workspace = world": one workspace can be many repos, so membership is their union, declared not guessed (the scan and its security are the client's). Exhaustiveness is a property of *coverage*, not *work*: every member is checked every turn so no drift hides, but unchanged members cost only a detect — the full-repo cost is git's to bound (what it tracks) and the client's to bound (`hide`), never the engine's to pay re-reading what hasn't moved.
1193
1203
 
1194
- **Migration path.** `session_constraints.effect` gains `repo`; the three renames (`add`→`pick`, `ignore`→`hide`, `read-only`→`view`) are wire-surface changes on `session.constrain`. Forest resolution iterates declared repos (was one `ls-files` at root). The change-detect adds a per-member stored signal — mtime+size or content hash, and *that choice is the EMI reliability bound* — gating the existing materialize. `PLURNK_SERVICE_GIT_ALLOWED` (the hard ceiling) and `PLURNK_SERVICE_GIT_AUTO` (the default declaration) are the git flags (§membership-git-flags). Tenancy / cross-session shared workspaces still require a `workspaces` table lifting constraints off `session_constraints`.
1204
+ **Migration path.** `workspace_constraints.effect` gains `repo`; the three renames (`add`→`pick`, `ignore`→`hide`, `read-only`→`view`) are wire-surface changes on `workspace.constrain`. Forest resolution iterates declared repos (was one `ls-files` at root). The change-detect adds a per-member stored signal — mtime+size or content hash, and *that choice is the EMI reliability bound* — gating the existing materialize. `PLURNK_SERVICE_GIT_ALLOWED` (the hard ceiling) and `PLURNK_SERVICE_GIT_AUTO` (the default declaration) are the git flags (§membership-git-flags). Tenancy / cross-workspace shared workspaces still require a `workspaces` table lifting constraints off `workspace_constraints`.
1195
1205
 
1196
1206
  ### §grinder Budget enforcement: the grinder
1197
1207
 
@@ -1200,11 +1210,11 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
1200
1210
  **Decision — a pre-LLM grinder, fired only on actual overflow.** In `Engine.runTurn`, after the packet is assembled (`PacketBuilder.buildRequestPacket`) and before `provider.generate`, the assembled render-weight (§tokenomics) is measured against the ceiling. At or under → the packet ships untouched; the grinder never trims speculatively or "helpfully." {§grinder-overflow-only} On overflow it folds the newest turn boundary's rows (errors exempt), then hard-stops if that isn't enough:
1201
1211
 
1202
1212
  - **One rule, every turn: fold the NEWEST memories — never history.** THE DOCTRINE, the project's animating narrative: the log is the model's memory and the model ALONE curates it (FOLD/KILL). The grinder never reaches back into history — it only blocks NEW memories from landing when there is no room, forcing the model to do its own housekeeping. On overflow it folds, in one set-op, the still-open rows of the **newest turn boundary**: the immediately-prior turn's emissions and the current turn's pre-model rows (foists, wake surfaces — every current-turn row at grind time is engine-written). Turn 1 is the **same rule**, not a case: no prior turn exists, so its own foists are the newest material. Folded, not deleted: rows and bodies persist and are re-OPENable, so log *history* is preserved while the render collapses to coordinates. {§grinder-layer1-rollback}
1203
- - **Errors AND the prompt are exempt.** The grinder never folds an `op='error'` row (the budget-overflow it just minted, a parse failure, an action failure) — errors are the model's durable, curatable record of what went wrong; folding them the moment they matter would blind it to a recurring failure. Nor does it fold the **user prompt** (`plurnk:///prompt/…`, #382): the task the engine foisted is FRAME, not the model's curatable memory — the engine never reclaims the definition of the task it set. Both stay OPEN until the model itself FOLDs or KILLs them. {§grinder-errors-exempt}
1213
+ - **Errors, the prompt, AND the plan are exempt.** The grinder never folds an `op='error'` row (the budget-overflow it just minted, a parse failure, an action failure) — errors are the model's durable, curatable record of what went wrong; folding them the moment they matter would blind it to a recurring failure. Nor does it fold the **user prompt** (`plurnk:///prompt/…`, #382): the task the engine foisted is FRAME, not the model's curatable memory — the engine never reclaims the definition of the task it set. Nor a **PLAN row** (#465, owner ruling): the checklist is the model's orientation surface at exactly the moment the grinder fires — folding the plan alongside the work it plans would collapse the reasoning thread a recovery turn steers by, and plans are concise by rule, so exempting them reclaims almost nothing. All three stay OPEN until the model itself FOLDs or KILLs them. {§grinder-errors-exempt}
1204
1214
  - **The hard overflow is a RECOVERY TURN first** {§grinder-hard-413-recovery} — (Q4, owner ruling: recoverable strike, NO margin). The overflow lives in foldable HISTORY the model owns, and the grinder never touches history (§grinder-layer1-rollback) — so the first hard overflow, when the packet is over the POLICY ceiling but still within PHYSICS (the provider's real window minus the decode reserve), is sent ANYWAY, once: a minted `budget_overflow` steer states the fact, the remedy (KILL/FOLD history; the budget table lists the heaviest), and the consequence — and the turn counts a strike (`budgetStruck`). The model curates → the next fitting turn clears the grant (a later overflow earns a fresh recovery; chronic overflow still strikes out via the rail). A recovery turn that CONCLUDES is a legitimate 200 — finishing is a way to stop overflowing. **No 95% margin**: the pointer fires at 100% of budget — a margin masks the signal, and the decode is separately reserved.
1205
1215
  - **Hard stop.** A physically-unsendable packet (over the provider's real window too — physics doesn't negotiate), or a SECOND consecutive hard overflow after the recovery turn (the model was told and declined), abandons the loop at **413 Content Too Large** (`engine_loop_set_status`). Its sibling engine-imposed terminals are HTTP-precise too: `maxTurns` → 429, a strike-out → 500 (508 when cycle-driven) — no longer the old catch-all 499. No further passes. {§grinder-hard-413-abort}
1206
1216
 
1207
- - **Output truncation states its cause** {§tokenomics-output-truncated} — a `finish=length` turn was guillotined at the decode pool (`REASONING + ASSISTANT`, one undifferentiated `max_tokens` — §tokenomics-window-partition), and its parse errors are truncation ARTIFACTS: without the cause stated, the model reads "unclosed block" and fixes syntax forever instead of emitting less (run29: unclamped cloud reasoning + a 9-edit emission vs a 12288 pool; the binding cap was plurnk's own — `completion 12281`). One terse `output_truncated` row (413) leads the artifact rows it explains, naming the cap and the remedy. The parse rows stay — the record never hides — and the partition's bluntness for unclamped cloud reasoning (think-tokens and emission-tokens sharing one pool) is a known limit: sizing REASONING to the model class is the operator lane; a per-API split decode budget is a providers-contract candidate, owner-gated.
1217
+ - **Output truncation states its cause** {§tokenomics-output-truncated} — a `finish=length` turn was guillotined at the decode pool (`REASONING + COMPLETION`, one undifferentiated `max_tokens` — §tokenomics-window-partition), and its parse errors are truncation ARTIFACTS: without the cause stated, the model reads "unclosed block" and fixes syntax forever instead of emitting less (run29: unclamped cloud reasoning + a 9-edit emission vs a 12288 pool; the binding cap was plurnk's own — `completion 12281`). One terse `output_truncated` row (413) leads the artifact rows it explains, naming the cap and distinguishing its two shapes so the model reads the right cause: content cut **mid-op** (a valid prefix dispatched; the parse errors are the severed tail) vs the pool consumed with **nothing emitted** (reasoning ran away — the parse `must begin with PLAN` is an artifact of the empty emission, not a malformed turn). The row states the fact, not a remedy. The parse rows stay — the record never hides — and the partition's bluntness for unclamped cloud reasoning (think-tokens and emission-tokens sharing one pool) is a known limit: sizing REASONING to the model class is the operator lane; a per-API split decode budget is a providers-contract candidate, owner-gated (run52 made it concrete — a cloud reasoner spent the entire 65536 pool thinking, emitting zero content, three times).
1208
1218
 
1209
1219
  - **Fetch-fits-free — the pressure law** {§tokenomics-fetch-fits-free} — under budget pressure, a retrieval larger than the headline's Tokens Free arrives ALREADY FOLDED: the result lands in the next build, the build overflows, and the grinder's one rule folds the newest boundary — which is exactly that result. The model never sees an oversized fetch open, however many times it re-fetches (the read→grind→re-read spiral: run24 on grok, the jumbo fixture on gemma — five turns of narrowing asks, three grind-strikes, 500). The engine's numbers are honest and the grinder is doctrine-correct; the model's ONLY working lever is ordering: FOLD history first (the Heaviest-items table is the target list), then fetch within the room made. The 413 error row states this law verbatim — the signal fires exactly when the lesson applies. Corollary: each turn accrues ~a couple hundred tokens of unavoidable meta (the mirror row, PLAN, budget growth), so a Tokens Free below that accretion is a dead state the NEXT build inherits — conclude or fold before it reaches zero, not at zero.
1210
1220
 
@@ -1220,27 +1230,27 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
1220
1230
 
1221
1231
  ### §env-delta The environment delta: what changed since the model last looked
1222
1232
 
1223
- **Question.** The manifest (§packet) is a live directory of what *exists*, re-derived each turn — but it carries *state*, not *events*. When a shared entry changes between a run's turns — a sibling run edits it, a tracked file diverges on disk (§membership) — the model's prior READ is now stale, and the manifest's new line count is a fact it would have to *diff against its own memory* to notice. The manifest also cannot say *who* changed it; with more than one actor in a session, provenance is load-bearing. What surfaces change — losslessly, attributably, without curating, and **without a per-run shadow of the world** (§machine-processes forbids one)?
1233
+ **Question.** The manifest (§packet) is a live directory of what *exists*, re-derived each turn — but it carries *state*, not *events*. When a shared entry changes between a worker's turns — a sibling worker edits it, a tracked file diverges on disk (§membership) — the model's prior READ is now stale, and the manifest's new line count is a fact it would have to *diff against its own memory* to notice. The manifest also cannot say *who* changed it; with more than one actor in a workspace, provenance is load-bearing. What surfaces change — losslessly, attributably, without curating, and **without a per-worker shadow of the world** (§machine-processes forbids one)?
1224
1234
 
1225
- **Decision — pull from the shared log; no snapshot.** Every edit is *already* a span-carrying `log_entries` row (§edit-result-render), so a run needs no stored state of its own: at pre-turn it surfaces *other actors'* EDITs on shared entries **since its own last turn** (`log_entries.at` past the run's most recent prior `turns.timestamp` — both already in the log) and materializes each as a **folded** `EDIT` in its log, reusing the originating row's span and cause. "Since I last looked" is a fact about the run's own turns, never a snapshot it cannot see (§machine-processes). The set is **exhaustive and unranked** — every change, no relevance order — but **not content-free**: the edited region of a change that *happened* is a faithful record, not the index regrowing. Volume is FOLD's to manage (deltas land folded) and the grinder's under budget — never the engine's to manage by gutting the payload.
1235
+ **Decision — pull from the shared log; no snapshot.** Every edit is *already* a span-carrying `log_entries` row (§edit-result-render), so a worker needs no stored state of its own: at pre-turn it surfaces *other actors'* EDITs on shared entries **since its own last turn** (`log_entries.at` past the worker's most recent prior `turns.timestamp` — both already in the log) and materializes each as a **folded** `EDIT` in its log, reusing the originating row's span and cause. "Since I last looked" is a fact about the worker's own turns, never a snapshot it cannot see (§machine-processes). The set is **exhaustive and unranked** — every change, no relevance order — but **not content-free**: the edited region of a change that *happened* is a faithful record, not the index regrowing. Volume is FOLD's to manage (deltas land folded) and the grinder's under budget — never the engine's to manage by gutting the payload.
1226
1236
 
1227
- **Form — a folded log entry, `origin=plurnk`, carrying `source`.** A delta is a `log_entries` row: an **`EDIT`** ("an EDIT happened to X"), `origin=plurnk`, **`expanded=0`** (folded — listed, collapsed to its coordinate until the model OPENs it), carrying the **`source`** column (the cause). A log entry, not a transient frame section, because a run's timeline must be **self-contained** — a forked run carries everything it observed (§machine-processes). `source` renders as `run="<id>"` / `run="file"` in the meta line, **omitted when the cause is the owning run itself** — a third attribution axis, distinct from `run_id` (whose log owns the row) and `origin` (the actor *type*).
1237
+ **Form — a folded log entry, `origin=plurnk`, carrying `source`.** A delta is a `log_entries` row: an **`EDIT`** ("an EDIT happened to X"), `origin=plurnk`, **`expanded=0`** (folded — listed, collapsed to its coordinate until the model OPENs it), carrying the **`source`** column (the cause). A log entry, not a transient frame section, because a worker's timeline must be **self-contained** — a forked worker carries everything it observed (§machine-processes). `source` renders as `"worker":"<id>"` / `"worker":"file"` in the entry's object, **omitted when the cause is the owning worker itself** — a third attribution axis, distinct from `worker_id` (whose log owns the row) and `origin` (the actor *type*).
1228
1238
 
1229
- **The filesystem is an actor — the `plurnk` run.** A real cross-run edit is a *faithful record*: the sibling issued the op, `source=<run id>`. An out-of-band disk change is a *fiction*: no op happened, but `EDIT` is the only grammar the model has for "your world changed," so the engine narrates the drift as a `source=file` EDIT to keep the model's perspective aligned with what its own tooling would show. It has no real author, so the reserved **`plurnk` run** (§actor-boundary) narrates it — at pre-turn it compares each member file to its entry (the §membership EMI re-read) and logs a `source=file` alignment EDIT for any divergence. Every run pulls that through the one delta path, exactly like a sibling's edit; the fs needs no special case.
1239
+ **The filesystem is an actor — the `plurnk` worker.** A real cross-worker edit is a *faithful record*: the sibling issued the op, `source=<worker id>`. An out-of-band disk change is a *fiction*: no op happened, but `EDIT` is the only grammar the model has for "your world changed," so the engine narrates the drift as a `source=file` EDIT to keep the model's perspective aligned with what its own tooling would show. It has no real author, so the reserved **`plurnk` worker** (§actor-boundary) narrates it — at pre-turn it compares each member file to its entry (the §membership EMI re-read) and logs a `source=file` alignment EDIT for any divergence. Every worker pulls that through the one delta path, exactly like a sibling's edit; the fs needs no special case.
1230
1240
 
1231
1241
  **No coalescing.** The fs nets *inherently* — one fiction per file is `editedSpan(entry-as-of-last-align, disk-now)`, the net of any number of disk changes, captured by the single pre-turn pass. Sibling edits do **not** net: they are real, discrete events already in the log, replayed faithfully (folded). A "net span" across unrelated edits would destroy the record and conflate the fs state-diff with the sibling event-replay — the asymmetry is correct.
1232
1242
 
1233
1243
  **Passive — computed at build, never forces a turn.** A delta materializes only while a packet assembles, so a change has nowhere to land until something else has already started a turn — it cannot wake an idle model. "Inform, never override." Urgency that genuinely needs the model routes through the *voice* door (an inject), never the environment door promoting itself to a turn.
1234
1244
 
1235
- **Rationale.** "The model knows its world moved" becomes a property of *reading the shared log at build* — 100% coverage by construction, with zero run-private state beside the log. The engine records each change faithfully (the EDIT it was, showing its result) and hands the model the wheel; it never ranks, selects, or folds on its behalf.
1245
+ **Rationale.** "The model knows its world moved" becomes a property of *reading the shared log at build* — 100% coverage by construction, with zero worker-private state beside the log. The engine records each change faithfully (the EDIT it was, showing its result) and hands the model the wheel; it never ranks, selects, or folds on its behalf.
1236
1246
 
1237
- **Migration path.** Built. The per-run world-snapshot the architecture forbade (§machine-processes) is **deleted**; its `[§machine-processes-run-is-its-log]` conformance test is now green. The pull + the `plurnk`-run fs narration replace it.
1247
+ **Migration path.** Built. The per-worker world-snapshot the architecture forbade (§machine-processes) is **deleted**; its `[§machine-processes-worker-is-its-log]` conformance test is now green. The pull + the `plurnk`-run fs narration replace it.
1238
1248
 
1239
1249
  ### §edit-result-render EDIT log rows render their result, not their input
1240
1250
 
1241
1251
  **Question.** An EDIT's log row exists so the model has a record of what it did. Re-emitting the model's *input* statement (the tx heredoc) records the *intent* but not the *outcome* — the model still has to READ the entry back to confirm "did it land, what does it look like now." And a system delta-EDIT (§env-delta) has no input statement at all. What should an EDIT row's body be?
1242
1252
 
1243
- **Decision — the edited area as it looks now.** An EDIT row renders the **resulting span**: the edited region of the entry *after* the write, line-numbered, with a couple of lines of context above and below. The model sees post-edit state inline — no confirming READ — and the same rendering serves the model's own EDITs and the system delta-EDITs (§env-delta) identically. The meta line still carries op + target, so "I EDITed X" stays legible; the body says "and here's X now."
1253
+ **Decision — the edited area as it looks now.** An EDIT row renders the **resulting span**: the edited region of the entry *after* the write, line-numbered, with a couple of lines of context above and below. The model sees post-edit state inline — no confirming READ — and the same rendering serves the model's own EDITs and the system delta-EDITs (§env-delta) identically. The entry's object still carries op + target, so "I EDITed X" stays legible; the body says "and here's X now."
1244
1254
 
1245
1255
  **Scope.** The span is computed at edit time — the write range and the result are both known then — and stored on the EDIT's `rx`; the render reads it. A large span is bounded like any rendered slice, and FOLD collapses it to the coordinate when the model doesn't need it.
1246
1256
 
@@ -1248,18 +1258,18 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
1248
1258
 
1249
1259
  ### §dual-yolo Dual-YOLO: server- and client-side auto-accept
1250
1260
 
1251
- **Question.** A side-effecting op proposes (§exec) — dispatch pauses at 202 awaiting a client accept/reject (§engine-rails, §methods). But two unrelated needs want to skip the human gate: a service running *headless* (a benchmark, a CI job, a fixture — there may be no client at all), and a *human* who wants "stop asking me" ergonomics in an interactive session. One flag, or two mechanisms?
1261
+ **Question.** A side-effecting op proposes (§exec) — dispatch pauses at 202 awaiting a client accept/reject (§engine-rails, §methods). But two unrelated needs want to skip the human gate: a service running *headless* (a benchmark, a CI job, a fixture — there may be no client at all), and a *human* who wants "stop asking me" ergonomics in an interactive workspace. One flag, or two mechanisms?
1252
1262
 
1253
1263
  **Decision — two distinct, complementary mechanisms.** Auto-accept lives at two layers that never substitute for each other:
1254
1264
 
1255
1265
  - **Server-side YOLO** — a per-loop flag, `loops.flags.yolo=true`, set via `loop.run({flags:{yolo:true}})`. The engine auto-resolves the proposal **in-process** — the in-tree `yolo` listener reads the pending proposal and accepts it without any `loop.resolve` ever crossing the wire. No client need be connected. {§dual-yolo-server-yolo-auto-accept} Its uses are non-interactive: benchmarks, CI runs, internal automation, test fixtures. Client apps deliberately do **not** expose it — it is not end-user ergonomics.
1256
- - **Client-side YOLO** — the *client's* own setting (`--yolo` / `PLURNK_YOLO`). The daemon emits the `loop/proposal` notification exactly as always; the client immediately answers `loop.resolve({decision:"accept"})`. The wire roundtrip still happens and the daemon stays **unaware** the acceptance was automatic — indistinguishable from a fast human. Its use is the interactive "stop bothering me" session.
1266
+ - **Client-side YOLO** — the *client's* own setting (`--yolo` / `PLURNK_YOLO`). The daemon emits the `loop/proposal` notification exactly as always; the client immediately answers `loop.resolve({decision:"accept"})`. The wire roundtrip still happens and the daemon stays **unaware** the acceptance was automatic — indistinguishable from a fast human. Its use is the interactive "stop bothering me" workspace.
1257
1267
 
1258
1268
  **The notification carries the flag.** `loop/proposal` carries `flags` (§notifications), `yolo` among them, so a client attached to a *server*-YOLO loop can suppress its review UI — those proposals resolve in-process before any human could react, and rendering a doomed review prompt is noise. {§dual-yolo-proposal-carries-flags}
1259
1269
 
1260
- **Server-YOLO is not blind — it refuses a stale clobber.** Auto-accept is not accept-everything: when an EDIT's target diverged on disk *this turn* — a `source=file` env-delta landed in the run's log since the model's prior turn — the model's EDIT is based on a stale read, and accepting it would silently overwrite the ambient change. The engine flags such a proposal `staleClobberRisk`, and the server-YOLO listener **rejects** it (the reject's outcome is forensics-only, never in the model's rx) rather than accepting; the model sees an ordinary reject and can re-READ the current content and retry. The guard is the engine's, on the auto-accept path it owns — it brackets the read→propose window (server-YOLO only), while the write-back compare-and-swap (§membership-edit-write-cas) brackets the propose→write window on every accept path. {§dual-yolo-stale-clobber-reject}
1270
+ **Server-YOLO is not blind — it refuses a stale clobber.** Auto-accept is not accept-everything: when an EDIT's target diverged on disk *this turn* — a `source=file` env-delta landed in the worker's log since the model's prior turn — the model's EDIT is based on a stale read, and accepting it would silently overwrite the ambient change. The engine flags such a proposal `staleClobberRisk`, and the server-YOLO listener **rejects** it (the reject's outcome is forensics-only, never in the model's rx) rather than accepting; the model sees an ordinary reject and can re-READ the current content and retry. The guard is the engine's, on the auto-accept path it owns — it brackets the read→propose window (server-YOLO only), while the write-back compare-and-swap (§membership-edit-write-cas) brackets the propose→write window on every accept path. {§dual-yolo-stale-clobber-reject}
1261
1271
 
1262
- **Why two.** They answer different questions. Server-side asks *"is a human in the loop at all?"* — and when the answer is no, dispatch must not block on a `loop.resolve` that will never come. Client-side asks *"does this human want to review each one?"* — a presentation choice that leaves the protocol untouched. Collapsing them would either force a client onto every headless run or leak an interactive preference into the engine's dispatch path. They are orthogonal by construction: the engine gate and the human gate, each bypassable on its own terms.
1272
+ **Why two.** They answer different questions. Server-side asks *"is a human in the loop at all?"* — and when the answer is no, dispatch must not block on a `loop.resolve` that will never come. Client-side asks *"does this human want to review each one?"* — a presentation choice that leaves the protocol untouched. Collapsing them would either force a client onto every headless worker or leak an interactive preference into the engine's dispatch path. They are orthogonal by construction: the engine gate and the human gate, each bypassable on its own terms.
1263
1273
 
1264
1274
  **Migration path.** Built. `loops.flags.yolo` persists and the `yolo` listener (`src/server/yolo.ts`) auto-resolves; `loop/proposal` already carries `flags`. Client-side YOLO is wholly the client's (`@plurnk/plurnk`) concern — the service offers nothing to build for it beyond the `loop.resolve` RPC it already has.
1265
1275
 
@@ -1273,7 +1283,7 @@ The CAS is the **hard backstop**, at the moment of writing, on every accept path
1273
1283
  type PacketSection = {
1274
1284
  name: string; // stable id: definition, tools, schemes, system-policy, project-policy, budget, prompt, errors, log, git, requirements — or a plugin's own
1275
1285
  slot: "system" | "user"; // the prompt-cache boundary; system-slot sections build the cache-stable system message
1276
- header: string | null; // "## Plurnk Service X", or null (definition renders verbatim)
1286
+ header: string | null; // "## X", or null (definition renders verbatim)
1277
1287
  content: string; // rendered markdown — what the model saw
1278
1288
  tokens: number; // measured render-weight
1279
1289
  };
@@ -1288,9 +1298,11 @@ type Packet = {
1288
1298
 
1289
1299
  The wire projection (`PacketWire.renderSlot`) groups sections by slot into the system + user ChatMessages; the digest re-renders the same stored sections byte-for-byte.
1290
1300
 
1291
- **Prompt as a first-class entry.** Each loop's prompt is written on loop start as a plurnk-origin `EDIT` against `plurnk://prompt/<run>/<loop>/<N>` (indexable, body channel, text/markdown); injected mid-loop prompts land the same way at their turn's slot. The **User Prompts** section sits at the system packet's very bottom as a PATHS-ONLY list (the errors shape `* plurnk://prompt/<run>/<loop>/<N>`, no bodies): every prompt the loop holds stays listed and READable by address, so prior prompts are never silently lost and recovering them is never a curation imposition. {§prompt-auto-read} **The address is run-qualified** (#382): entries are session-scoped while loop sequences are per-run every run's first loop is sequence 1, so an unqualified `/prompt/1/1` is one key shared by a parent and every WORK-spawned sister, and a worker's turn-1 foist would overwrite the parent's task. The run id in the path is `/proc/<pid>`-style process qualification one filesystem, collision-free coordinates; every writer and query builds the path through one boundary (`plurnk-uri.promptPathname`/`promptLoopPrefix`). The model never constructs the addressit READs what the section lists. {§prompt-run-qualified} The prompt's CONTENT reaches the model through a foisted auto-`READ` of its own entry `<1,12>` (the first twelve lines), or `<1,-1>` when the prompt runs fewer than twelve (the whole-read form doubles as teaching) dispatched on the first turn for the loop prompt and on the landing turn for each injected prompt, so an arriving prompt always opens in the log like any retrieval. The foisted `EDIT`'s **log row is folded by default** (`expanded=0`): the body arrives via the auto-READ, so the log keeps the write for forensics while collapsing the duplicate, re-OPENable like any fold (§open-fold). {§prompt-fold} **A model `FOLD` targeting a prompt row is ILLEGAL** (#382 — a weak model in a housekeeping turn folded its own task auto-READ and lost the plot): the fold is refused (*"Illegal attempt to FOLD a user prompt. Use KILL if you want it removed."*), so the frame can't be accidentally hidden while `KILL` still deletes it deliberately, preserving the model's curation of a prompt it genuinely wants gone. A glob sweep silently spares the prompt and folds the rest. {§prompt-fold-illegal}
1301
+ **The arrival law.** {§arrival-law} Every body lands as an entry and appears in the packet as a priced meta line; how much body rides OPEN on arrival is set by INVITATION. **Self-invited** content what this worker addressed by its own op (READ/FIND/OPEN results, its own exec streams) rides open within free headroom; over, it arrives folded (the grinder + budget_overflow contract). **Pushed** content text another actor put in front of this worker (the user prompt, a child's deliverable, a sibling irc) rides the PREVIEW: whole when under the bound, the head + the cut statement + the pull address when over. The bound is `PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES` (default 16) lines AND 80×N charsthe char cap guards single-line bombs. The trust line is authorship: a worker's own ask is its curation, priced in advance; another actor's push is unratified by the recipient at any size (run111: a child's legally-terminal 19,363-token deliverable landed whole in its parent, was relayed into a 40k child prompt, and cascaded the pipeline #499). A child whose loop ends non-2xx already lands FOLDED (the termination-delta insert); the preview bounds the ratified-giant case. New lanes inherit the law at the arrival boundary — a future scheme or module cannot reopen the hole by adding a feature. The preview is enforced at RENDER for every pushed lane the prompt foist's line-slice bounds lines, and the render's char cap cuts what a line-slice cannot (a single-line char-bomb prompt renders as its 80×N-char head + the cut statement + its address).
1302
+
1303
+ **Prompt as a first-class entry.** Each loop's prompt is written on loop start as a plurnk-origin `EDIT` against `plurnk://prompt/<worker>/<loop>/<N>` (indexable, body channel, text/markdown); injected mid-loop prompts land the same way at their turn's slot. The **User Prompts** section sits at the system packet's very bottom as a PATHS-ONLY list (the errors shape — `* plurnk://prompt/<worker>/<loop>/<N>`, no bodies): every prompt the loop holds stays listed and READable by address, so prior prompts are never silently lost and recovering them is never a curation imposition. {§prompt-auto-read} **The address is worker-qualified** (#382): entries are workspace-scoped while loop sequences are per-worker — every worker's first loop is sequence 1, so an unqualified `/prompt/1/1` is one key shared by a parent and every WORK-spawned sister, and a worker's turn-1 foist would overwrite the parent's task. The worker id in the path is `/proc/<pid>`-style process qualification — one filesystem, collision-free coordinates; every writer and query builds the path through one boundary (`plurnk-uri.promptPathname`/`promptLoopPrefix`). The model never constructs the address — it READs what the section lists. {§prompt-worker-qualified} The prompt's CONTENT reaches the model through a foisted auto-`READ` of its own entry — `<1,16>` (the §arrival-law preview bound, `PLURNK_SERVICE_ARRIVAL_PREVIEW_LINES`), or `<1,-1>` when the prompt runs under it (the whole-read form doubles as teaching) — dispatched on the first turn for the loop prompt and on the landing turn for each injected prompt, so an arriving prompt always opens in the log like any retrieval. The foisted `EDIT`'s **log row is folded by default** (`expanded=0`): the body arrives via the auto-READ, so the log keeps the write for forensics while collapsing the duplicate, re-OPENable like any fold (§open-fold). {§prompt-fold} **A model `FOLD` targeting a prompt row is ILLEGAL** (#382 — a weak model in a housekeeping turn folded its own task auto-READ and lost the plot): the fold is refused (*"Illegal attempt to FOLD a user prompt. Use KILL if you want it removed."*), so the frame can't be accidentally hidden — while `KILL` still deletes it deliberately, preserving the model's curation of a prompt it genuinely wants gone. A glob sweep silently spares the prompt and folds the rest. {§prompt-fold-illegal}
1292
1304
 
1293
- **The entry catalog.** The catalog is the **complete, unranked directory** of what a session holds, served by `FIND(scheme:///**)` — one per-scheme array, queried on demand, not a single materialized entry (there is no `plurnk:///manifest.json`; the per-scheme arrays replaced it). Built in the schemes layer (`_entry-manifest.catalogRowsFor`); a per-turn derivation pump (`maintainDerivations`) refreshes the deep channels the rows report. A scheme's array is **every entry it holds, in no relevance order**, each `{ path, seconds?, tags?, channels: { <uri>: { mimetype, tokens, lines } } }` — every channel keyed by the URI the model READs (the default channel by the bare path, a non-default by `path#channel`), so it reaches a channel without guessing. `tags` is present only when the entry carries `entry_tags` — its own categorization, surfaced so the model can `FIND` by tag. The model ranks and filters the catalog itself by querying it (task-aware); the catalog never ranks for it — the instant it did, it would be an index again. `tokens` is the provider's live count recounted at render, `lines` the content extent from `Mimetypes.process().totalLines`. The catalog never lists itself. {§packet-catalog}
1305
+ **The entry catalog.** The catalog is the **complete, unranked directory** of what a workspace holds, served by `FIND(scheme:///**)` — one per-scheme array, queried on demand, not a single materialized entry (there is no `plurnk:///manifest.json`; the per-scheme arrays replaced it). Built in the schemes layer (`_entry-manifest.catalogRowsFor`); a per-turn derivation pump (`maintainDerivations`) refreshes the deep channels the rows report. A scheme's array is **every entry it holds, in no relevance order**, each `{ path, seconds?, tags?, channels: { <uri>: { mimetype, tokens, lines } } }` — every channel keyed by the URI the model READs (the default channel by the bare path, a non-default by `path#channel`), so it reaches a channel without guessing. `tags` is present only when the entry carries `entry_tags` — its own categorization, surfaced so the model can `FIND` by tag. The model ranks and filters the catalog itself by querying it (task-aware); the catalog never ranks for it — the instant it did, it would be an index again. `tokens` is the provider's live count recounted at render, `lines` the content extent from `Mimetypes.process().totalLines`. The catalog never lists itself. {§packet-catalog}
1294
1306
 
1295
1307
  ### §telemetry user.telemetry — model-facing runtime telemetry
1296
1308
 
@@ -1311,7 +1323,7 @@ The `log` section is the durable audit; the `errors` section surfaces both — t
1311
1323
  - `budget` per §tokenomics: turn-weight and heaviest-entries tables with `tokenCeiling`/`tokenUsage`/`tokensFree`.
1312
1324
  - **One uniform error channel.** EVERY failure — a failed action, an actionless parse failure, and every engine-rail failure (budget overflow, max-commands, the idle/premature steers) — is an `op='error'` `log_entries` row with `status_rx ≥ 400`. No per-category handling, no bespoke ephemeral relationship. The `errors` section is a derived index over those rows (the current turn and the immediately-prior one): one terse `<status> log:///<coord>` link per row, nothing else. The term and full detail live on the foldable row, READ via the link. {§telemetry-uniform-error-channel} **The pointer rule: every ≥400 line points at a record that states its why.** A model-op failure is the MODEL'S OWN op result — the op row carries its failure message on its META LINE (`"error":"…"` — packet-wire renders it folded or open, {§log-row-self-explains}), and the errors line is a terse pointer at that row. No separate item is minted for op failures (the retired action_failure mint dressed op results as `source:"engine"` faults with empty messages — the jumbo model chased a phantom "engine run 400 error" for ten turns). Actionless failures (parse errors, truncation, rails) still mint `op='error'` items — no op row exists to self-explain. Ops MUST populate real failure messages (`rx.error`/`reason`); a bare `{status:400}` is a contract wart. Genuine engine-internal faults CRASH (fail-hard) and never mint model-facing rows.
1313
1325
  - **Terse rows.** An error row's body is a status code and the canonical term — `Budget Overflow`, `Max Commands Exceeded`, `Idle Turn`, `Premature Termination` — never prose, hints, or advice. The packet (requirements, grammar) teaches recovery; the row names the fault. Letting the model infer what to do from the fact (and the log) beats handing it instructions it will second-guess.
1314
- - **Notices** — the few events that are NOT log rows (a provider's `grammar_unenforced`, which points at the model's own emission via a content-offset) render one terse line under `## Plurnk Service Errors` by their typed `position`, never a JSON dump. The notice buffer drains on read — each appears on exactly one packet. {§telemetry-drain-on-read}
1326
+ - **Notices** — the few events that are NOT log rows (a provider's `grammar_unenforced`, which points at the model's own emission via a content-offset) render one terse line under `## Errors` by their typed `position`, never a JSON dump. The notice buffer drains on read — each appears on exactly one packet. {§telemetry-drain-on-read}
1315
1327
  - **Gamification policy (rummy precedent, plugins/error/error.js).** The model sees errors that **happened** — its actions failed, its emission didn't parse, its ops were truncated, it overflowed the window. The model does NOT see the engine's accounting *about* errors: strike streaks, cycle detection, sudden-death thresholds, no-ops bookkeeping. Surfacing internal state creates a gamification surface where the model optimizes for engine metrics (manufacturing a clean turn to reset the strike counter, e.g.) instead of the task. Engine bookkeeping drives abandonment silently; the model just sees its actual failures.
1316
1328
 
1317
1329
  **The error rows (one channel) + the only non-log notices:**
@@ -1333,21 +1345,21 @@ The `log` section is the durable audit; the `errors` section surfaces both — t
1333
1345
 
1334
1346
  Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeeping stay engine-internal — they drive abandonment silently per the gamification policy. EVERY error — a failed action, an actionless parse failure, and every engine-rail failure (budget overflow, max-commands, the idle/premature steers) — is a LOG ITEM (`log:///<coord>`, `op='error'`, `status_rx ≥ 400`), foldable and re-OPENable, with its terse term on the row. The `errors` section surfaces a derived pointer to each. There is **no bespoke `error://` scheme** and no ephemeral per-category error buffer: errors live in the log, addressable + curatable like any row — not a separate namespace, not a drain-on-read side channel. {§telemetry-no-error-scheme}
1335
1347
 
1336
- **Client surface.** Engine NOTICES broadcast live via the `telemetry/event` WS notification — same envelope as the model's drained copy (`{ source, kind, level, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema), the moment they land, scoped to the loop's session (a `grammar_unenforced` snippet in a debug panel, a session timeline). ERRORS do not broadcast on this surface: they are log rows, and the client reads them the same way the model curates them — `log.read` / the `log/entry` notification, the durable log. {§telemetry-telemetry-event-notify}
1348
+ **Client surface.** Engine NOTICES broadcast live via the `telemetry/event` WS notification — same envelope as the model's drained copy (`{ source, kind, level, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema), the moment they land, scoped to the loop's workspace (a `grammar_unenforced` snippet in a debug panel, a workspace timeline). ERRORS do not broadcast on this surface: they are log rows, and the client reads them the same way the model curates them — `log.read` / the `log/entry` notification, the durable log. {§telemetry-telemetry-event-notify}
1337
1349
 
1338
- **Turn-lifecycle liveness.** The provider `generate()` call is the one long, opaque window in a turn — submit → first committed op is provider latency plus a full first-turn generation (tens of seconds on a local model); a static client screen there is indistinguishable from a hang. The engine brackets `generate()` with two `telemetry/event` NOTICES (`source: "engine:turn"`, `level: "info"`): `turn_awaiting_model` the instant it calls the provider, `turn_generated` when the call resolves and op-parsing begins — a legible "thinking… → working…" heartbeat, NOT model token-content (that stays out of the Log, a paradigm break). Both are suppressed on an aborted loop and broadcast to the session like any notice (§telemetry-telemetry-event-notify). Optional intra-generation ticks (a moving counter during the long wait) are a later provider-contract enhancement (an `onProgress` on `generate()`, the `embedBatch` shape); the two-beat bracket needs no provider change. {§turn-lifecycle}
1350
+ **Turn-lifecycle liveness.** The provider `generate()` call is the one long, opaque window in a turn — submit → first committed op is provider latency plus a full first-turn generation (tens of seconds on a local model); a static client screen there is indistinguishable from a hang. The engine brackets `generate()` with two `telemetry/event` NOTICES (`source: "engine:turn"`, `level: "info"`): `turn_awaiting_model` the instant it calls the provider, `turn_generated` when the call resolves and op-parsing begins — a legible "thinking… → working…" heartbeat, NOT model token-content (that stays out of the Log, a paradigm break). Both are suppressed on an aborted loop and broadcast to the workspace like any notice (§telemetry-telemetry-event-notify). Optional intra-generation ticks (a moving counter during the long wait) are a later provider-contract enhancement (an `onProgress` on `generate()`, the `embedBatch` shape); the two-beat bracket needs no provider change. {§turn-lifecycle}
1339
1351
 
1340
1352
  **Content-offset position.** An emission-level error carries a `position: { type: "content-offset", line, column }` into the model's own emission — a parse-error LOG ROW (op='error', §model-entry) and a content-offset NOTICE (e.g. a provider's `grammar_unenforced`) both report the line, not the bytes. The model resolves it against its own emission: the `model` mirror row (§model-entry, always folded) holds the line-numbered emission, and the model `READ`s the folded row at the cited lines — surgical, budget-bounded, no auto-opening. No snippet is embedded — that would duplicate an emission the model can already introspect. {§telemetry-content-offset-pointer}
1341
1353
 
1342
1354
  ### §tools user.tools — the capability sheet
1343
1355
 
1344
- The tools capability lines render **titleless**, directly under the `definition` (plurnk.md) section — the examples flow on from plurnk.md with no separate header — and **above** `## Plurnk Service Requirements`, so the model sees what it can *do* before the rules it must follow. Each enabled capability contributes one line via `PacketBuilder.#collectTools`; the section is omitted when nothing is enabled. {§tools-capability-sheet}
1356
+ The tools capability sheet renders **titleless**, directly under the `definition` (plurnk.md) section — the examples flow on from plurnk.md with no separate header — and **above** `## Recap`, so the model sees what it can *do* before the rules it must follow. The op examples ride a `plurnk` fence (matching the Schemes catalog, #441 — one packet, one shape for op-example sheets), assembled by `PacketBuilder.#collectTools`; a prose notice (e.g. the EXEC-disabled line) stays prose beside the fence, and the section is omitted when nothing is enabled. {§tools-capability-sheet}
1345
1357
 
1346
- **Contributors: the wired executor tags.** Each available executor tag *with an example* contributes ONE line — its canonical usage — via the shared `teachingLine` (identical shape to the scheme directory, §schemes); its doc is materialized at `plurnk://docs/<tag>.md` and discovered via the turn-1 `FIND(plurnk://docs/**)` foist, not linked inline (#270). A tag with no example contributes nothing; `PLURNK_SERVICE_DOCS_EXCLUDE` drops a named tag's line + doc. The boot `ExecutorRegistry` probes availability per tag, retiring the model's blind `<<EXEC[sh]…`.
1358
+ **Contributors: the wired executor tags.** Each available executor tag *with an example* contributes ONE bare op — its canonical usage — into the `plurnk` fence (identical shape to the scheme directory, §schemes); its doc is materialized at `plurnk://docs/<tag>.md` and discovered via the turn-1 `FIND(plurnk://docs/**)` foist, not linked inline (#270). A tag with no example contributes nothing; `PLURNK_SERVICE_DOCS_EXCLUDE` drops a named tag's line + doc. The boot `ExecutorRegistry` probes availability per tag, retiring the model's blind `<<EXEC[sh]…`.
1347
1359
 
1348
1360
  ### §schemes user.schemes — the scheme directory
1349
1361
 
1350
- A `## Plurnk Service Schemes` section renders in the system slot **after the definition (plurnk.md — grammar + imperatives) and the tools sheet** — a terse directory of the scheme families available this session, so the model knows what URI schemes exist before it acts. Each scheme that ships a `manifest.example` contributes ONE line — its canonical usage (no scheme prefix; the example self-documents). The doc is NOT linked inline (#270) — it is materialized at `plurnk://docs/<scheme>.md` and discovered via the turn-1 `FIND(plurnk://docs/**)` foist, keeping the raw packet free of doc links. The in-tree core schemes author their depth in `docs/<name>.md` (loaded at boot, shipped with the package); daughter schemes ship `manifest.documentation`. The verbose semantics live in that pull doc (materialized like any entry, READ on demand), not the hot path — terse pushes, depth pulls, via the same `teachingLine` as the tools sheet (§tools). A scheme with no example (provisional) is omitted; `PLURNK_SERVICE_DOCS_EXCLUDE` drops a named scheme's line + doc. {§schemes-directory}
1362
+ A `## Schemes` section renders in the system slot **after the definition (plurnk.md — grammar + imperatives) and the tools sheet** — a terse directory of the scheme families available this workspace, so the model knows what URI schemes exist before it acts. Each scheme that ships a `manifest.example` contributes ONE bare op — its canonical usage (no scheme prefix; the example self-documents) — into a `plurnk` fence (§tools shares the shape, #441). The doc is NOT linked inline (#270) — it is materialized at `plurnk://docs/<scheme>.md` and discovered via the turn-1 `FIND(plurnk://docs/**)` foist, keeping the raw packet free of doc links. The in-tree core schemes author their depth in `docs/<name>.md` (loaded at boot, shipped with the package); daughter schemes ship `manifest.documentation`. The verbose semantics live in that pull doc (materialized like any entry, READ on demand), not the hot path — terse pushes, depth pulls, the examples fenced like the tools sheet (§tools). A scheme with no example (provisional) is omitted; `PLURNK_SERVICE_DOCS_EXCLUDE` drops a named scheme's line + doc. {§schemes-directory}
1351
1363
 
1352
1364
  ### §inject system.inject — the operator injection
1353
1365
 
@@ -1355,13 +1367,13 @@ When `PLURNK_SERVICE_PACKET_INJECT` names a readable markdown file, its content
1355
1367
 
1356
1368
  ### §policy system.policy — the client's policy injection
1357
1369
 
1358
- Two sections ride the system slot **below the operator notes, above budget**: `## Plurnk Service Policy` from `PLURNK_SERVICE_POLICY` (default `~/.plurnk/AGENTS.md`) and `## Project Policy` from `PLURNK_SERVICE_PROJECT` (default `<projectRoot>/AGENTS.md`, resolved relative to the session root). AGENTS.md is **policy** — the client's authoritative rules promoted into the privileged zone — NOT a curatable, foldable, READ-able entry; the model cannot FOLD it away. A default-absent path is silent (the section is omitted); an explicit override (env set) that fails to read fails the turn hard — a deliberate setting with a broken path is a misconfig, surfaced not hidden. Read per-turn so edits take effect live. Reference/scratch docs are NOT policy — they ride `PLURNK_SERVICE_MD_*` (materialized as READ-able entries, §operator-config), which is where the dev-notes AGENTS.md used to hold belong. {§policy-sections}
1370
+ Two sections ride the system slot **below the operator notes, above budget**: `## Policy` from `PLURNK_SERVICE_POLICY` (default `~/.plurnk/AGENTS.md`) and `## Project Policy` from `PLURNK_SERVICE_PROJECT` (default `<projectRoot>/AGENTS.md`, resolved relative to the workspace root). AGENTS.md is **policy** — the client's authoritative rules promoted into the privileged zone — NOT a curatable, foldable, READ-able entry; the model cannot FOLD it away. A default-absent path is silent (the section is omitted); an explicit override (env set) that fails to read fails the turn hard — a deliberate setting with a broken path is a misconfig, surfaced not hidden. Read per-turn so edits take effect live. Reference/scratch docs are NOT policy — they ride `PLURNK_SERVICE_MD_*` (materialized as READ-able entries, §operator-config), which is where the dev-notes AGENTS.md used to hold belong. {§policy-sections}
1359
1371
 
1360
1372
  **The scheme self-doc contract.** `example` is the hot-path one-liner; `documentation` is the deep doc — the exact shape execs already use (`example` + `documentation`). `SchemeRegistry.teach()` renders the directory; `docEntries()` materializes the docs (per loop.run, alongside the operator docs). `documentation` rides a service-side `SchemeManifest` extension until plurnk-schemes#25 lands it in the contract.
1361
1373
 
1362
1374
  ### §requirements The requirements section — static per-turn rules
1363
1375
 
1364
- Rendered at the END of the user packet under `## Plurnk Service Requirements` {§requirements-requirements-render-last} — closest to the assistant turn so the contract the model has to honor is the most recent text it sees. The header is omitted entirely when the requirements string is empty. {§requirements-requirements-omitted-when-empty} Contains rules the grammar block doesn't cover (canonical example: "Conclude the loop with `<<SEND[200]:answer:SEND`"). The op syntax leads the section. PLAN is mandated unconditionally by plurnk.md §Imperatives (grammar 0.70 requires every turn to lead with `<<PLAN`), so the service injects no separate plan directive here.
1376
+ Rendered at the END of the user packet under `## Recap` {§requirements-requirements-render-last} — closest to the assistant turn so the contract the model has to honor is the most recent text it sees. The header is omitted entirely when the requirements string is empty. {§requirements-requirements-omitted-when-empty} Contains rules the grammar block doesn't cover (canonical example: "Conclude the loop with `<<SEND[200]:answer:SEND`"). The op syntax leads the section. PLAN is mandated unconditionally by plurnk.md §Imperatives (grammar 0.70 requires every turn to lead with `<<PLAN`), so the service injects no separate plan directive here.
1365
1377
 
1366
1378
  **Sourcing:** caller supplies the string via `runLoop({ requirements })` / `runTurn({ requirements })`. Plurnk-service exposes `PATHS.defaultRequirements` (resolves `PLURNK_SERVICE_REQUIREMENTS` env → in-package `requirements.md`). No DB cascade — same string every turn.
1367
1379
 
@@ -1480,7 +1492,7 @@ Same rule applies across Known, Unknown, Skill, Plurnk, File. Effective mimetype
1480
1492
 
1481
1493
  A log row renders its **result body** for the content-returning ops — `READ@200` (the content it pulled) and `FIND@200` (the catalog rows / matched entries it returned) — under the query's fence, mimetype-driven per the rules above; every other op re-emits its statement. FIND included: the model must see what a find *returned*, not just its echoed query, and the turn-0 foisted `FIND(scheme:///**)` reaches the packet through this branch — without it the catalog preview is invisible. {§render-rule-find-renders-result}
1482
1494
 
1483
- An `EDIT` log row renders its **resulting span** — the edited area as it looks now (`rx.span`), under the target's fence — not the input statement: the log reads "and here's X now," so the model sees its edit's effect. The meta line still carries op + target; the model's own EDITs and the system delta-EDITs (§env-delta) render identically; an emptied span → meta line only. With no span stored, the row falls back to re-emitting the statement (the heredoc the model wrote). {§edit-result-render}
1495
+ An `EDIT` log row renders its **resulting span** — the edited area as it looks now (`rx.span`), under the target's fence — not the input statement: the log reads "and here's X now," so the model sees its edit's effect. The object still carries op + target; the model's own EDITs and the system delta-EDITs (§env-delta) render identically; an emptied span → the object's meta alone (no body). With no span stored, the row falls back to re-emitting the statement (the heredoc the model wrote). {§edit-result-render}
1484
1496
 
1485
1497
  The `N:\t` prefix is presentation/reference per plurnk.md ("not part of the source"); stripped before any matcher operation on the log entry.
1486
1498