@plurnk/plurnk-service 0.21.0 → 0.23.0

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 (89) hide show
  1. package/README.md +6 -6
  2. package/SPEC.md +347 -291
  3. package/dist/Paths.d.ts +4 -0
  4. package/dist/Paths.d.ts.map +1 -1
  5. package/dist/Paths.js +20 -0
  6. package/dist/Paths.js.map +1 -1
  7. package/dist/content/edited-span.js +2 -2
  8. package/dist/content/edited-span.js.map +1 -1
  9. package/dist/content/matcher.js +1 -1
  10. package/dist/content/matcher.js.map +1 -1
  11. package/dist/content/mimetype-binary.js +1 -1
  12. package/dist/content/mimetype-binary.js.map +1 -1
  13. package/dist/core/ChannelWrite.js +2 -2
  14. package/dist/core/ChannelWrite.js.map +1 -1
  15. package/dist/core/Engine.d.ts.map +1 -1
  16. package/dist/core/Engine.js +72 -46
  17. package/dist/core/Engine.js.map +1 -1
  18. package/dist/core/ExecutorRegistry.js +1 -1
  19. package/dist/core/ExecutorRegistry.js.map +1 -1
  20. package/dist/core/PluginLoader.js +1 -1
  21. package/dist/core/PluginLoader.js.map +1 -1
  22. package/dist/core/SchemeRegistry.js +1 -1
  23. package/dist/core/SchemeRegistry.js.map +1 -1
  24. package/dist/core/caps/DbChannelCaps.js +1 -1
  25. package/dist/core/caps/DbChannelCaps.js.map +1 -1
  26. package/dist/core/caps/DbEntryCaps.js +1 -1
  27. package/dist/core/caps/DbEntryCaps.js.map +1 -1
  28. package/dist/core/caps/SchemeCtxImpl.js +1 -1
  29. package/dist/core/caps/SchemeCtxImpl.js.map +1 -1
  30. package/dist/core/fork.d.ts +5 -0
  31. package/dist/core/fork.d.ts.map +1 -0
  32. package/dist/core/fork.js +47 -0
  33. package/dist/core/fork.js.map +1 -0
  34. package/dist/core/git-membership.js +4 -4
  35. package/dist/core/git-membership.js.map +1 -1
  36. package/dist/core/git-state.js +1 -1
  37. package/dist/core/git-state.js.map +1 -1
  38. package/dist/core/packet-wire.d.ts.map +1 -1
  39. package/dist/core/packet-wire.js +18 -13
  40. package/dist/core/packet-wire.js.map +1 -1
  41. package/dist/schemes/File.js +6 -6
  42. package/dist/schemes/File.js.map +1 -1
  43. package/dist/schemes/_entry-crud.js +2 -2
  44. package/dist/schemes/_entry-crud.js.map +1 -1
  45. package/dist/schemes/_entry-find.js +1 -1
  46. package/dist/schemes/_entry-find.js.map +1 -1
  47. package/dist/schemes/_entry-graph.js +1 -1
  48. package/dist/schemes/_entry-graph.js.map +1 -1
  49. package/dist/schemes/_entry-manifest.js +1 -1
  50. package/dist/schemes/_entry-manifest.js.map +1 -1
  51. package/dist/schemes/_entry-ops.js +6 -6
  52. package/dist/schemes/_entry-ops.js.map +1 -1
  53. package/dist/schemes/_entry-semantic.js +1 -1
  54. package/dist/schemes/_entry-semantic.js.map +1 -1
  55. package/dist/schemes/_entry-send.js +3 -3
  56. package/dist/schemes/_entry-send.js.map +1 -1
  57. package/dist/server/ClientConnection.js +3 -3
  58. package/dist/server/ClientConnection.js.map +1 -1
  59. package/dist/server/Daemon.d.ts +2 -2
  60. package/dist/server/Daemon.js +4 -4
  61. package/dist/server/Daemon.js.map +1 -1
  62. package/dist/server/MethodRegistry.js +1 -1
  63. package/dist/server/MethodRegistry.js.map +1 -1
  64. package/dist/server/envelope.d.ts +1 -0
  65. package/dist/server/envelope.d.ts.map +1 -1
  66. package/dist/server/envelope.js +19 -6
  67. package/dist/server/envelope.js.map +1 -1
  68. package/dist/server/methods/_dispatchAsPlurnk.d.ts +7 -0
  69. package/dist/server/methods/_dispatchAsPlurnk.d.ts.map +1 -0
  70. package/dist/server/methods/_dispatchAsPlurnk.js +23 -0
  71. package/dist/server/methods/_dispatchAsPlurnk.js.map +1 -0
  72. package/dist/server/methods/entry_read.js +1 -1
  73. package/dist/server/methods/entry_read.js.map +1 -1
  74. package/dist/server/methods/log_read.js +1 -1
  75. package/dist/server/methods/log_read.js.map +1 -1
  76. package/dist/server/methods/loop_cancel.js +1 -1
  77. package/dist/server/methods/loop_cancel.js.map +1 -1
  78. package/dist/server/methods/loop_resolve.js +1 -1
  79. package/dist/server/methods/loop_resolve.js.map +1 -1
  80. package/dist/server/methods/loop_run.d.ts.map +1 -1
  81. package/dist/server/methods/loop_run.js +31 -4
  82. package/dist/server/methods/loop_run.js.map +1 -1
  83. package/dist/server/methods/session_constraints.js +2 -2
  84. package/dist/server/methods/session_constraints.js.map +1 -1
  85. package/dist/server/yolo.js +1 -1
  86. package/dist/server/yolo.js.map +1 -1
  87. package/migrations/0000-00-00.01_schema.sql +7 -7
  88. package/package.json +11 -11
  89. package/requirements.md +2 -1
package/SPEC.md CHANGED
@@ -2,15 +2,15 @@
2
2
 
3
3
  Canonical contracts plurnk-service exposes, architecture it implements, promises it makes to the constellation (`plurnk-grammar`, `plurnk-providers`, `plurnk-schemes`, `plurnk-mimetypes`, `plurnk-execs`, the user-facing `plurnk` CLI). `AGENTS.md` covers process; this file covers contract.
4
4
 
5
- Section numbers are stable. Promise anchors `{§<id>}` mark individual assertions; tests cite them in their names (`test("[§<id>] …", …)`). `test/intg/spec-anchors.test.ts` fails on orphan citations and reports gaps. Anchors are drift-grounding, not a forcing function.
5
+ The `§` sigil marks one thing: a stable terse tag. A section is a tag (`§discovery`); a promise under it is a child tag (`§discovery-discover`) whose prefix names its section. Headings, prose cross-refs, and promise anchors all use this one namespace — no digits, so renumbering is a non-event. Promise anchors `{§<tag>}` mark individual assertions; tests cite them in their names (`test("[§<tag>] …", …)`). `test/intg/spec-anchors.test.ts` fails on orphan citations and reports gaps. Anchors are drift-grounding, not a forcing function.
6
6
 
7
7
  ---
8
8
 
9
- ## §0 Glossary
9
+ ## §glossary Glossary
10
10
 
11
11
  Canonical meanings. When a doc, comment, test name, or commit message uses one of these words, it means exactly what's written here. Drift is a bug.
12
12
 
13
- ### §0.1 Lifecycle terms
13
+ ### §lifecycle-terms Lifecycle terms
14
14
 
15
15
  | Term | Meaning |
16
16
  |---|---|
@@ -24,37 +24,37 @@ Canonical meanings. When a doc, comment, test name, or commit message uses one o
24
24
  | **action** | One executed op. Action and op are the same thing in different states (op = parsed; action = executed). The execution produces a log_entries row at `log://<L>/<T>/<S>/<op>`. |
25
25
  | **dispatch** | The engine routing a statement to its scheme's op handler. |
26
26
 
27
- ### §0.2 Storage terms
27
+ ### §storage-terms Storage terms
28
28
 
29
29
  | Term | Meaning |
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
33
  | **scope** | `"agent"` or `"session"`. Determines who reads. Agent-scope entries visible to every run; session-scope entries to that session's runs. |
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 §3.6; author contract: [plurnk-schemes](https://github.com/plurnk/plurnk-schemes). |
35
- | **mimetype** | A channel's content type. Drives the handler that produces the structural projections (`symbols`, `deepJson`, `deepXml`). Consumption surface §4.5; author contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). |
36
- | **provider** | An LLM transport. Implements `generate({messages, signal})` against a wire protocol. Consumption surface §2; author contract: [plurnk-providers](https://github.com/plurnk/plurnk-providers). |
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
+ | **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
+ | **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). |
37
37
 
38
- ### §0.3 State / status
38
+ ### §state-terms State / status
39
39
 
40
40
  Independent axes on entries and channels. Confusion across them is a recurring source of bugs.
41
41
 
42
42
  | Term | Type | Meaning |
43
43
  |---|---|---|
44
- | **status** | HTTP int | Outcome of an operation. Carried on `log_entries.status_rx`, returned from op handlers. Per the catalogue (§3.5). |
44
+ | **status** | HTTP int | Outcome of an operation. Carried on `log_entries.status_rx`, returned from op handlers. Per the catalogue (§send-dispatch). |
45
45
  | **channel state** | `static \| active \| closed \| errored` | Streaming lifecycle of a channel's content. Metadata, not gating — engine renders content regardless of state. |
46
46
  | **entry state** | `proposed \| resolved \| cancelled` | Proposal lifecycle. `proposed` = pending client accept; `resolved` = side effect happened; `cancelled` = client rejected. Distinct from channel state. |
47
47
  | **outcome** | `string \| null` | Short reason for `failed`/`cancelled` (`"permission:403"`, `"aborted"`, `"not_found"`). Opaque to most callers. |
48
48
 
49
- ### §0.4 Writer / authority
49
+ ### §authority-terms Writer / authority
50
50
 
51
51
  | Term | Meaning |
52
52
  |---|---|
53
53
  | **writer** | The identity authoring a write. One of `model \| client \| plurnk \| plugin`. Carried on `ctx.writer` for schemes; engine enforces `manifest.writableBy`. |
54
54
  | **origin** | Synonym for writer in log_entries (`log_entries.origin`). Historical naming; treat as equivalent. |
55
- | **writable_by** | The set of writers a scheme accepts. Subset of `{model, client, plurnk, plugin}`. Engine rejects writes outside the set with 403; the rejection is logged as the action-entry (§7.1 action-entry-as-outcome). |
55
+ | **writable_by** | The set of writers a scheme accepts. Subset of `{model, client, plurnk, plugin}`. Engine rejects writes outside the set with 403; the rejection is logged as the action-entry (§subscriptions action-entry-as-outcome). |
56
56
 
57
- ### §0.5 Engine rails
57
+ ### §engine-rails Engine rails
58
58
 
59
59
  | Term | Meaning |
60
60
  |---|---|
@@ -64,10 +64,10 @@ Independent axes on entries and channels. Confusion across them is a recurring s
64
64
  | **sudden death** | The last `MAX_STRIKES` turns of a loop's `MAX_LOOP_TURNS` window emit soft 429 warnings so the model can wrap up cleanly. `soft=true`: no strike, no streak increment. |
65
65
  | **mode** | `"ask" \| "act"`. Per-loop. Ask = read-only (no side-effecting ops); act = full surface. |
66
66
  | **flag** | Per-loop boolean shaping the active toolset: `yolo` (auto-accept proposals), `noWeb`, `noInteraction`, `noProposals`. |
67
- | **proposal** | A deferred side-effecting action awaiting client accept/reject. State machine: `proposed → resolved` or `proposed → cancelled`. `yolo` short-circuits to immediate. |
67
+ | **proposal** | A deferred side-effecting action awaiting client accept/reject (full lifecycle §proposal). State machine: `proposed → resolved` or `proposed → cancelled`. `yolo` short-circuits to immediate. |
68
68
  | **resolution** | Client's accept/reject of a proposal via `op.resolve` RPC. |
69
69
 
70
- ### §0.6 Packet terms
70
+ ### §packet-terms Packet terms
71
71
 
72
72
  | Term | Meaning |
73
73
  |---|---|
@@ -75,7 +75,7 @@ Independent axes on entries and channels. Confusion across them is a recurring s
75
75
  | **log** | `packet.system.log`. Chronological list of `log_entries` in scope this turn. |
76
76
  | **render** | The act of computing the packet from current DB state at turn boundaries. Mimetype handlers fire at render time. |
77
77
 
78
- ### §0.7 Test taxonomy
78
+ ### §test-taxonomy Test taxonomy
79
79
 
80
80
  | Tier | Location | LLM | Substrate |
81
81
  |---|---|---|---|
@@ -86,9 +86,11 @@ Independent axes on entries and channels. Confusion across them is a recurring s
86
86
 
87
87
  ---
88
88
 
89
- ## §1 Architecture
89
+ ## §arch Architecture
90
90
 
91
- ### §1.1 Ecosystem
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).
92
+
93
+ ### §ecosystem Ecosystem
92
94
 
93
95
  The plurnk project is a modular monorepo-of-repos in the `@plurnk/*` npm namespace. Each repo has one published package and one agent who owns it; cross-repo coordination happens through issues, not shared code. This service sits in the middle of that ecosystem and is its **runtime substrate** — the daemon other repos plug into.
94
96
 
@@ -107,48 +109,90 @@ The grammar is the contract. The frameworks consume the contract and add author-
107
109
 
108
110
  **This service's central role:** sole consumer of every author-facing framework contract (one set of integrations across the ecosystem), sole producer of the engine's runtime behavior (one canonical implementation of dispatch, log, packet wire), and sole orchestrator of cross-scheme operations (COPY/MOVE flow through engine-mediated `readEntry` / `writeEntry` / `deleteEntry`, never scheme-to-scheme). Most cross-repo coordination flows through us — we file the consumer-need issues at upstream repos, adopt their decisions, document the surface in SPEC.
109
111
 
110
- ### §1.2 In-process architecture
112
+ ### §in-process In-process architecture
111
113
 
112
114
  Engine library + admin CLI + daemon. Four plug points:
113
115
 
114
- - **Providers** (§2) — LLM transports. Engine sends a turn's messages, receives raw content + usage; engine parses the content into `PlurnkStatement[]`.
115
- - **Schemes** (§3) — addressable resources. Every op targets a URI; scheme handler interprets paths under its prefix and owns its storage substrate.
116
- - **Mimetypes** (§4) — content interpretation. Render-time handlers consume channel content; framework owns the dispatch.
117
- - **Executors** (§6.8 / §10) — EXEC runtime dispatch. Subprocess shells, search backends, future tool runtimes.
116
+ - **Providers** (§provider) — LLM transports. Engine sends a turn's messages, receives raw content + usage; engine parses the content into `PlurnkStatement[]`.
117
+ - **Schemes** (§scheme) — addressable resources. Every op targets a URI; scheme handler interprets paths under its prefix and owns its storage substrate.
118
+ - **Mimetypes** (§mimetype) — content interpretation. Render-time handlers consume channel content; framework owns the dispatch.
119
+ - **Executors** (§exec / §bundled-set) — EXEC runtime dispatch. Subprocess shells, search backends, future tool runtimes.
118
120
 
119
- The engine dispatches ops, persists state to SQLite, orchestrates cross-scheme COPY/MOVE (§6.46.5), writes the log. Substantive behavior lives in the four plug points.
121
+ The engine dispatches ops, persists state to SQLite, orchestrates cross-scheme COPY/MOVE (§copymove), writes the log. Substantive behavior lives in the four plug points.
120
122
 
121
123
  The grammar (`@plurnk/plurnk-grammar`) owns parser + AST contract. Schemes receive parsed statement fragments via dispatch.
122
124
 
123
125
  Server posture: this package is the runtime. User-facing CLI lives in `plurnk` and consumes the library API (`src/index.ts` + `PATHS`).
124
126
 
127
+ ### §actor-boundary The actor boundary: isolation by run, two doors, self-hosting
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?
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 hide a row. {§actor-boundary-isolation} {§actor-boundary-origin-not-filter}
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.*
136
+
137
+ {§actor-boundary-two-doors}
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}
140
+
141
+ **Passive wake.** An idle run wakes on exactly two events — a prompt injected into it (voice; user or system) or a stream-status transition it subscribes to (§channel-state). A delta never wakes a run; it queues and drains at the next turn one of those produces (§env-delta). {§actor-boundary-passive-wake}
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}
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`. What remains is *repatriation* — the inline plurnk dispatches still bolted into the model's loop (the §env-delta materialization, the manifest build, git auto-add) move onto it.
146
+
147
+ **The keystone's first use: operator reference docs.** `PLURNK_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_MD_*` convention cascades to clients. {§actor-boundary-doc-injection}
148
+
149
+ ### §machine-processes The machine and its processes: session, run, fork
150
+
151
+ **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 view of the workspace would live — grow subtle, then metastasize. Drawn once, they vanish.
152
+
153
+ **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 `plurnk://manifest.json` (§packet) — under one membership overlay (§membership). Exactly one filesystem and one overlay per session; neither is per-run. A **run** is a process whose entire 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 (`indexed`). A run owns **no entries** and **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*.
154
+
155
+ **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}
156
+
157
+ **One overlay.** Membership — `git ls-files ∪ add − ignore` with 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}
158
+
159
+ **A run is its log — and nothing beside.** The run-private state is the log and only the log. *What I am looking at* (OPEN/FOLD) is `log_entries.indexed`, 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. The log is the whole of a run's memory. {§machine-processes-run-is-its-log}
160
+
161
+ **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}
162
+
163
+ **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}
164
+
165
+ **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 view" answers itself once the world/log line is drawn.
166
+
167
+ **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 sheds its per-run watermark — a run's only memory is its log, so drift is the broadcast (run-caused) and the build-time disk-vs-entry diff (ambient), both landing as log entries, never a per-run snapshot.
168
+
125
169
  ---
126
170
 
127
- ## §2 Provider Contract
171
+ ## §provider Provider Contract
128
172
 
129
173
  Author-facing contract: [plurnk-providers#1](https://github.com/plurnk/plurnk-providers/issues/1). Below: consumption surface + engine→provider guarantees.
130
174
 
131
- ### §2.1 Consumption surface
175
+ ### §provider-surface Consumption surface
132
176
 
133
177
  Three entry points:
134
178
 
135
- - `provider.generate({messages, signal})` — once per turn; returns `{ assistant: { content, reasoning, usage, finishReason, model }, assistantRaw }`. **Engine parses `assistant.content`** into `PlurnkStatement[]` via `@plurnk/plurnk-grammar`. {§2.1-generate}
136
- - `provider.countTokens(text)` — synchronous, called at write-time (§14.2) and render-time. Non-negative integer. {§2.1-counttokens}
137
- - `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`. {§2.1-costfor}
179
+ - `provider.generate({messages, signal})` — once per turn; returns `{ assistant: { content, reasoning, usage, finishReason, model }, assistantRaw }`. **Engine parses `assistant.content`** into `PlurnkStatement[]` via `@plurnk/plurnk-grammar`. {§provider-surface-generate}
180
+ - `provider.countTokens(text)` — synchronous, called at write-time (§tokenomics) and render-time. Non-negative integer. {§provider-surface-counttokens}
181
+ - `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}
138
182
 
139
- Plus immutable identity: `provider.contextSize` (token total, or `null` → "no budget info"), read by the budget {§2.1-identity}; and `provider.model` — the instance identity the deferred model-switch recompute compares (§14.2-hot-switch-recompute), exposed but not yet consumed here.
183
+ 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.
140
184
 
141
- ### §2.2 Engine → provider guarantees
185
+ ### §provider-guarantees Engine → provider guarantees
142
186
 
143
187
  - `messages` is a complete prompt (`system_definition`, `persona`, `index`, `log`, `prompt`, `telemetry`, `system_requirements` pre-assembled). Provider does not reorder.
144
- - `signal` is wired to the run's AbortController. {§2.2-signal-wired}
145
- - `generate` is single-call per turn. No parallel calls on the same instance. {§2.2-single-call}
146
- - `assistantRaw` is opaque to the engine (forensics-only). {§2.2-assistantraw-opaque}
188
+ - `signal` is wired to the run's AbortController. {§provider-guarantees-signal-wired}
189
+ - `generate` is single-call per turn. No parallel calls on the same instance. {§provider-guarantees-single-call}
190
+ - `assistantRaw` is opaque to the engine (forensics-only). {§provider-guarantees-assistantraw-opaque}
147
191
  - `countTokens` is cheap by contract; engine calls frequently.
148
192
 
149
- ### §2.3 Provider instantiation
193
+ ### §provider-instantiation Provider instantiation
150
194
 
151
- Model alias parsing (`parseAliasesFromEnv` / `resolveActiveAlias`) lives in [`@plurnk/plurnk-providers`](https://github.com/plurnk/plurnk-providers). {§2.3-alias-resolution} Dynamic provider instantiation (`instantiateProvider` / `loadActiveProvider`) lives in `src/core/ProviderInstantiate.ts` here — `import()` resolves package specifiers relative to the calling module, so the dynamic-import path stays in the consumer where the `@plurnk/plurnk-providers-<vendor>` packages are installed.
195
+ Model alias parsing (`parseAliasesFromEnv` / `resolveActiveAlias`) lives in [`@plurnk/plurnk-providers`](https://github.com/plurnk/plurnk-providers). {§provider-instantiation-alias-resolution} Dynamic provider instantiation (`instantiateProvider` / `loadActiveProvider`) lives in `src/core/ProviderInstantiate.ts` here — `import()` resolves package specifiers relative to the calling module, so the dynamic-import path stays in the consumer where the `@plurnk/plurnk-providers-<vendor>` packages are installed.
152
196
 
153
197
  ```
154
198
  PLURNK_MODEL_gemma=openai/macher.gguf
@@ -158,29 +202,29 @@ PLURNK_MODEL=gemma
158
202
 
159
203
  First path segment = provider plugin; rest = provider's own model id.
160
204
 
161
- ### §2.4 Mock provider (sibling fixture)
205
+ ### §mock-provider Mock provider (sibling fixture)
162
206
 
163
- `Mock` (exported from `@plurnk/plurnk-providers`) — intg fixture + reference implementation. `{ contextSize, responses }` constructor; `generate` shifts from the queue. `MockResponse.assistant.ops?: PlurnkStatement[]` is a pre-parsed escape hatch the engine consumes directly when present; production providers don't expose this. {§2.4-mock-fixture}
207
+ `Mock` (exported from `@plurnk/plurnk-providers`) — intg fixture + reference implementation. `{ contextSize, responses }` constructor; `generate` shifts from the queue. `MockResponse.assistant.ops?: PlurnkStatement[]` is a pre-parsed escape hatch the engine consumes directly when present; production providers don't expose this. {§mock-provider-mock-fixture}
164
208
 
165
209
  ---
166
210
 
167
- ## §3 Scheme Contract
211
+ ## §scheme Scheme Contract
168
212
 
169
213
  Author-facing contract: [plurnk-schemes#1](https://github.com/plurnk/plurnk-schemes/issues/1). Below: what plurnk-service exposes to schemes and orchestrates over them.
170
214
 
171
- ### §3.1 Manifest
215
+ ### §scheme-manifest Manifest
172
216
 
173
- Per author contract. Each scheme declares a `static manifest: SchemeManifest` with `name`, `channels`, `defaultChannel`, `category`, `scope`, `writableBy`, `volatile`, `modelVisible`, optional `flags`. {§3.1-manifest} Identity match enforced at plugin load: `manifest.name` must equal `package.json#plurnk.name`.
217
+ Per author contract. Each scheme declares a `static manifest: SchemeManifest` with `name`, `channels`, `defaultChannel`, `category`, `scope`, `writableBy`, `volatile`, `modelVisible`, optional `flags`. {§scheme-manifest-manifest} Identity match enforced at plugin load: `manifest.name` must equal `package.json#plurnk.name`.
174
218
 
175
- ### §3.2 CRUD primitives
219
+ ### §crud CRUD primitives
176
220
 
177
- Per author contract (`readEntry` / `writeEntry` / `deleteEntry`). Engine drives cross-scheme COPY/MOVE/SEND[410] through these — the orchestration and its 404/409/415 semantics are anchored under §6.46.5. Each method is one SQL transaction; engine owns the outer transaction for orchestrations.
221
+ Per author contract (`readEntry` / `writeEntry` / `deleteEntry`). Engine drives cross-scheme COPY/MOVE/SEND[410] through these — the orchestration and its 404/409/415 semantics are anchored under §copymove. Each method is one SQL transaction; engine owns the outer transaction for orchestrations.
178
222
 
179
- ### §3.3 Op methods
223
+ ### §op-methods Op methods
180
224
 
181
- Per author contract (`edit`/`read`/`open`/`fold`/`find`/`send`/`exec?`). Engine dispatches by `PlurnkStatement.op`. {§3.3-op-dispatch} COPY and MOVE are NOT scheme methods — engine orchestrates over CRUD primitives (§6.46.5).
225
+ 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 (§copymove).
182
226
 
183
- ### §3.4 Cross-scheme orchestration
227
+ ### §orchestration Cross-scheme orchestration
184
228
 
185
229
  ```
186
230
  copy(source_path, dest_path, signal_tags, ctx):
@@ -198,22 +242,22 @@ move(source_path, dest_path, signal_tags, ctx):
198
242
  src_scheme.deleteEntry(source_pathname, ctx)
199
243
  ```
200
244
 
201
- Same- and cross-scheme operations share the orchestrator. Same-scheme COPY is not a special case. Orchestration behavior — 404/409/415, `move` = `copy` + `deleteEntry` — is anchored under §6.46.5.
245
+ Same- and cross-scheme operations share the orchestrator. Same-scheme COPY is not a special case. Orchestration behavior — 404/409/415, `move` = `copy` + `deleteEntry` — is anchored under §copymove.
202
246
 
203
- ### §3.5 SEND dispatch (status-code-as-verb)
247
+ ### §send-dispatch SEND dispatch (status-code-as-verb)
204
248
 
205
249
  Directed SEND (non-null path) routes to scheme's `send`. Status = intent:
206
250
 
207
251
  - `SEND[200](path)` — write body into resource (WS message, exec stdin).
208
- - `SEND[499](path)` — cancel active subscription (§7).
252
+ - `SEND[499](path)` — cancel active subscription (§stream).
209
253
 
210
- `SEND[410](path[#fragment])` also deletes the target entry/channel — an implemented side-effect, NOT taught to the model and with no live/demo surface. The model-facing delete idiom is KILL (the MOVE→`/dev/null` idiom is retired, §6.5).
254
+ `SEND[410](path[#fragment])` also deletes the target entry/channel — an implemented side-effect, NOT taught to the model and with no live/demo surface. The model-facing delete idiom is KILL (the MOVE→`/dev/null` idiom is retired, §move).
211
255
 
212
- Other status codes return 501 from entry-bearing schemes by default. {§3.5-entry-schemes-501-on-non-410}
256
+ Other status codes return 501 from entry-bearing schemes by default. {§send-dispatch-entry-schemes-501-on-non-410}
213
257
 
214
- Null-path SEND is broadcast (§6.7), engine-handled.
258
+ Null-path SEND is broadcast (§send), engine-handled.
215
259
 
216
- ### §3.6 Consumption surface
260
+ ### §scheme-surface Consumption surface
217
261
 
218
262
  Per-call context (`src/core/scheme-types.ts`):
219
263
 
@@ -238,29 +282,29 @@ Engine → scheme guarantees:
238
282
 
239
283
  - `ctx` is fresh per call. No mutation across calls.
240
284
  - `ctx.writer` reflects the actual writer at this dispatch.
241
- - `manifest.writableBy` checked BEFORE invocation; engine returns 403 directly on exclusion. {§3.6-writableby-403}
242
- - `ctx.signal` is wired to the run's AbortController (§2.2-signal-wired).
243
- - Scheme exceptions become the action-entry's outcome (status 500); summary surfaces in next turn's `packet.user.telemetry.errors[]` (§15.1). {§3.6-exception-500}
285
+ - `manifest.writableBy` checked BEFORE invocation; engine returns 403 directly on exclusion. {§scheme-surface-writableby-403}
286
+ - `ctx.signal` is wired to the run's AbortController (§provider-guarantees-signal-wired).
287
+ - Scheme exceptions become the action-entry's outcome (status 500); summary surfaces in next turn's `packet.user.telemetry.errors[]` (§telemetry). {§scheme-surface-exception-500}
244
288
 
245
- **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.provider.countTokens` (§14.2-tokens-stored-at-write). Raw DB writes bypass tokenization — out of API scope.
289
+ **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.provider.countTokens` (§tokenomics-tokens-stored-at-write). Raw DB writes bypass tokenization — out of API scope.
246
290
 
247
291
  ---
248
292
 
249
- ## §4 Mimetype Contract
293
+ ## §mimetype Mimetype Contract
250
294
 
251
295
  Author-facing contract: [plurnk-mimetypes](https://github.com/plurnk/plurnk-mimetypes). Below: firing semantics + consumption surface.
252
296
 
253
- **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 time. {§4-schemes-do-not-invoke-handlers}
297
+ **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 time. {§mimetype-schemes-do-not-invoke-handlers}
254
298
 
255
- ### §4.1 Manifest
299
+ ### §mimetype-manifest Manifest
256
300
 
257
- Per author contract. Manifest declares `kind: "mimetype"`; handler class declares `mimetype` (matches manifest name) and `glyph` (single emoji). Collisions fail-hard at boot per §9.
301
+ Per author contract. Manifest declares `kind: "mimetype"`; handler class declares `mimetype` (matches manifest name) and `glyph` (single emoji). Collisions fail-hard at boot per §plugin-discovery.
258
302
 
259
- ### §4.2 Methods
303
+ ### §mimetype-methods Methods
260
304
 
261
305
  Author contract owned by plurnk-mimetypes. plurnk-service consumes ONE entry point:
262
306
 
263
- - `Mimetypes.process(input)` — the projection entry point; returns the structural projections (`deepJson` / `deepXml` / `symbols` / `references`) + extent (`totalLines`). {§4.2-process-entry-point}
307
+ - `Mimetypes.process(input)` — the projection entry point; returns the structural projections (`deepJson` / `deepXml` / `symbols` / `references`) + extent (`totalLines`). {§mimetype-methods-process-entry-point}
264
308
 
265
309
  **The daughter projects; the service queries.** `Mimetypes.query()` exists in the author contract, but plurnk-service does NOT consume it. The service owns **all** dialect matching — glob, regex, jsonpath, xpath, `@graph`, `~semantic` — resolved in-tree over those projections plus its own indexes (`symbol_defs`/`symbol_refs`, FTS5, vectors). mimetypes is mimetype-*literate* (content→structure); the service is dialect-*literate* (structure→matches). The pattern-matching DSL is plurnk's defining surface — the service's authority, never a daughter's.
266
310
 
@@ -271,17 +315,17 @@ Cross-cutting promises service relies on:
271
315
  - Validation errors propagate (fail-hard).
272
316
  - Degraded projection (a `grammarMissing` marker) rather than throw when a grammar is absent.
273
317
 
274
- ### §4.3 What handlers do NOT do
318
+ ### §handler-bounds What handlers do NOT do
275
319
 
276
- - **Tokenization** — provider-bound (§2).
320
+ - **Tokenization** — provider-bound (§provider).
277
321
  - **Storage** — pure functions over content strings.
278
- - **Streaming** — handlers see whatever content is current; subscription registry lives between schemes and §7.
322
+ - **Streaming** — handlers see whatever content is current; subscription registry lives between schemes and §stream.
279
323
 
280
- ### §4.4 Bundled vs sibling handlers
324
+ ### §handler-bundling Bundled vs sibling handlers
281
325
 
282
326
  No mimetype handlers ship in-tree. Framework + every handler are siblings.
283
327
 
284
- ### §4.5 Consumption surface
328
+ ### §mimetype-surface Consumption surface
285
329
 
286
330
  plurnk-service is mimetype-illiterate. Engine hands channel content + mimetype label to `Mimetypes.process({content, hint})`; the manifest build uses `result.totalLines` for each channel's `lines`. Content reaches the model on READ, not as a rendered preview.
287
331
 
@@ -306,7 +350,7 @@ new Mimetypes({
306
350
 
307
351
  Fallback heuristic is a boot-before-provider-resolved tripwire.
308
352
 
309
- **Manifest build.** `EntryManifest.buildManifestBody` is the engine-side packet-assembly pass (the §4 firing point) that walks **every** entry. It calls `process({ content, hint })` per channel for the catalog's `lines` (`totalLines`) and, for the body channel, pulls `symbols`+`references` from the *same* call to (re)build the `@graph` symbol index (`symbol_defs`/`symbol_refs`) via `EntryGraph.populateFrom` — one parse, two projections:
353
+ **Manifest build.** `EntryManifest.buildManifestBody` is the engine-side packet-assembly pass (the §mimetype firing point) that walks **every** entry. It calls `process({ content, hint })` per channel for the catalog's `lines` (`totalLines`) and, for the body channel, pulls `symbols`+`references` from the *same* call to (re)build the `@graph` symbol index (`symbol_defs`/`symbol_refs`) via `EntryGraph.populateFrom` — one parse, two projections:
310
354
 
311
355
  ```ts
312
356
  const result = await mimetypes.process({ content: r.content, hint: r.mimetype }, { channels: ["symbols", "references"] });
@@ -320,39 +364,39 @@ if (isBody) await EntryGraph.populateFrom(db, sessionId, r.entry_id, result.symb
320
364
 
321
365
  ---
322
366
 
323
- ## §5 Channel Topology
367
+ ## §channels Channel Topology
324
368
 
325
- Every entry has named channels. **Channels are append-only content stores** keyed by `(entry_id, name)`. Schemes write content; the engine reads at turn boundaries; mimetype handlers interpret. {§5-channels-append-only}
369
+ Every entry has named channels. **Channels are append-only content stores** keyed by `(entry_id, name)`. Schemes write content; the engine reads at turn boundaries; mimetype handlers interpret. {§channels-channels-append-only}
326
370
 
327
- ### §5.1 Per-entry channels
371
+ ### §per-entry-channels Per-entry channels
328
372
 
329
- EDIT writes one channel per call — the channel resolved from the path's fragment (or the scheme's `defaultChannel` when no fragment). {§5.1-edit-writes-only-body}
373
+ EDIT writes one channel per call — the channel resolved from the path's fragment (or the scheme's `defaultChannel` when no fragment). {§per-entry-channels-edit-writes-only-body}
330
374
 
331
375
  No stored `preview` channel — channel content is pulled on READ, never previewed.
332
376
 
333
377
  Schemes MAY declare multiple channels (`exec`: stdout/stderr/stdin; `http`: body/header; SSE: per-event-type). Each goes in `manifest.channels` with mimetype pinned; rendered independently.
334
378
 
335
- ### §5.2 Entries carry no visibility
379
+ ### §no-visibility Entries carry no visibility
336
380
 
337
- Every entry is uniformly listed in `plurnk://manifest.json` (§15) and READable — entries have no per-run open/folded state. Context curation is the model's, on the **log** (OPEN/FOLD collapse/expand log rows, §6.3), never on entries.
381
+ Every entry is uniformly listed in `plurnk://manifest.json` (§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.
338
382
 
339
- ### §5.3 Mimetype is a (scheme, channel) property — never a default
383
+ ### §channel-mimetype Mimetype is a (scheme, channel) property — never a default
340
384
 
341
- Mimetype is declared by scheme manifest (§3.1) or supplied per-call for dynamic schemes. Writing a channel without a declared mimetype throws. No default mimetype anywhere.
385
+ Mimetype is declared by scheme manifest (§scheme-manifest) or supplied per-call for dynamic schemes. Writing a channel without a declared mimetype throws. No default mimetype anywhere.
342
386
 
343
- - Cross-mimetype COPY/MOVE → 415, never coerces (§6.4). {§5.3-cross-mimetype-415}
387
+ - Cross-mimetype COPY/MOVE → 415, never coerces (§copy). {§channel-mimetype-cross-mimetype-415}
344
388
 
345
- ### §5.5 Channel selection in the DSL
389
+ ### §channel-selection Channel selection in the DSL
346
390
 
347
391
  DSL targets a specific channel via the URL fragment (`#name`).
348
392
 
349
393
  Rules:
350
394
 
351
- 1. Fragment-less paths target the scheme's `defaultChannel`. {§5.5-fragmentless-targets-default-channel}
352
- 2. Paths with a fragment target the named channel. {§5.5-fragment-selects-named-channel}
353
- 3. Unknown channel name → 400. {§5.5-unknown-channel-400}
395
+ 1. Fragment-less paths target the scheme's `defaultChannel`. {§channel-selection-fragmentless-targets-default-channel}
396
+ 2. Paths with a fragment target the named channel. {§channel-selection-fragment-selects-named-channel}
397
+ 3. Unknown channel name → 400. {§channel-selection-unknown-channel-400}
354
398
  4. Schemes without `defaultChannel` reject fragment-less EDIT/READ.
355
- 5. Non-default channel EDIT requires entry to exist (404 if absent); default-channel EDIT creates. {§5.5-fragment-on-nonexistent-404}
399
+ 5. Non-default channel EDIT requires entry to exist (404 if absent); default-channel EDIT creates. {§channel-selection-fragment-on-nonexistent-404}
356
400
  | URI | Channel |
357
401
  |---|---|
358
402
  | `known://france/capital` | body (default) |
@@ -378,150 +422,172 @@ RPC params carry fragments inline via the `target` string (`{ target: "known://x
378
422
  <<log://1/1/0:...:log://1/1/0 — atomic log row
379
423
  ```
380
424
 
381
- ### §5.6 Channel state — metadata, not gating
425
+ ### §channel-state Channel state — metadata, not gating
382
426
 
383
- Each channel has `state ∈ {static, active, closed, errored}`. Metadata only, not an engine gate. {§5.6-state-is-metadata}
427
+ Each channel has `state ∈ {static, active, closed, errored}`. Metadata only, not an engine gate. {§channel-state-state-is-metadata}
384
428
 
385
429
  - `static` — content final, not being written. Entry schemes after EDIT.
386
430
  - `active` — scheme is writing (chunks arriving). Streaming schemes during accumulation.
387
431
  - `closed` — stream ended cleanly. Content final.
388
432
  - `errored` — stream ended in error. Content may be partial; reads return what accumulated.
389
433
 
390
- Schemes own transitions; UPDATE `entry_channels.state` as connection lifecycle progresses. {§5.6-schemes-own-state-transitions} State does not gate reads — schemes return accumulated `content` regardless (§5.6-state-is-metadata).
434
+ Schemes own transitions; UPDATE `entry_channels.state` as connection lifecycle progresses. {§channel-state-schemes-own-state-transitions} State does not gate reads — schemes return accumulated `content` regardless (§channel-state-state-is-metadata).
391
435
 
392
436
  Model uses state to anticipate growth between turns. Clients use state for UI (spinner / red border / etc.).
393
437
 
394
438
  ---
395
439
 
396
- ## §6 Op Surface
440
+ ## §op Op Surface
397
441
 
398
- Per-op semantics. AST shapes from `@plurnk/plurnk-grammar`'s `PlurnkStatement`. Engine dispatches by `op`; scheme implements per author contract (§3).
442
+ Per-op semantics. AST shapes from `@plurnk/plurnk-grammar`'s `PlurnkStatement`. Engine dispatches by `op`; scheme implements per author contract (§scheme).
399
443
 
400
- ### §6.1 EDIT
444
+ ### §edit EDIT
401
445
 
402
446
  AST: `{ op: "EDIT", target, body: string | null, signal: tags | null, lineMarker? }`.
403
447
 
404
- - Resolves target channel from fragment (§5.5); unknown channel → 400; undeclared in manifest → engine crash (§5.3).
405
- - Writes body; `body: null` clears. {§6.1-null-clears}- Returns `{ status: 201, entryId }` for new entries; `{ status: 200, entryId }` for content updates. {§6.1-status-201-200}
406
- - A write that changes nothing — identical content and no new tag — returns `{ status: 304, entryId }`, mirroring OPEN/FOLD's no-op (§6.3). {§6.1-noop-304}
407
- - Tags from `signal[]` apply additively via `entry_tags` (scheme may vary). {§6.1-tags-additive}
448
+ - Resolves target channel from fragment (§channel-selection); unknown channel → 400; undeclared in manifest → engine crash (§channel-mimetype).
449
+ - Writes body; `body: null` clears. {§edit-null-clears}- Returns `{ status: 201, entryId }` for new entries; `{ status: 200, entryId }` for content updates. {§edit-status-201-200}
450
+ - A write that changes nothing — identical content and no new tag — returns `{ status: 304, entryId }`, mirroring OPEN/FOLD's no-op (§open-fold). {§edit-noop-304}
451
+ - Tags from `signal[]` apply additively via `entry_tags` (scheme may vary). {§edit-tags-additive}
408
452
 
409
- ### §6.2 READ
453
+ ### §read READ
410
454
 
411
455
  AST: `{ op: "READ", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
412
456
 
413
- - Returns channel content + mimetype {§6.2-read-content}, or 404 {§6.2-read-404}.
414
- - `lineMarker` slices per §16.3.
415
- - `body` matcher dispatches through `Mimetypes.query` per §16.1 (all four dialects wired).
457
+ - Returns channel content + mimetype {§read-read-content}, or 404 {§read-read-404}.
458
+ - `lineMarker` slices per §slice-semantics.
459
+ - `body` matcher dispatches through `Mimetypes.query` per §matcher-dispatch (all four dialects wired).
416
460
 
417
- ### §6.3 OPEN / FOLD
461
+ ### §open-fold OPEN / FOLD
418
462
 
419
463
  AST: `{ op: "OPEN"|"FOLD", target, body: MatcherBody | null, signal: tags | null, lineMarker? }`.
420
464
 
421
- OPEN/FOLD operate on the **log** (`log://`) — the model's context-curation surface (§15). FOLD collapses a log row to its path; OPEN restores its body. Non-destructive: rows and bodies persist, re-OPENable. Entries carry no visibility (§5.2), so OPEN/FOLD against an entry scheme returns 501.
465
+ 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.
422
466
 
423
- ### §6.4 COPY (engine-orchestrated)
467
+ ### §copy COPY (engine-orchestrated)
424
468
 
425
469
  AST: `{ op: "COPY", target (source), body (destination), signal: tags | null, lineMarker? }`.
426
470
 
427
- Engine orchestrates over CRUD primitives (§3.2, §3.4):
471
+ Engine orchestrates over CRUD primitives (§crud, §orchestration):
428
472
 
429
- 1. `src_scheme.readEntry` → 404 if missing. {§6.4-missing-source-404}
430
- 2. `dst_scheme.readEntry` → conflict verdict, deferred until the written content is known (step 5): exists with identical content + tags → 304 (no-op, mirrors EDIT §6.1) {§6.4-noop-304}; exists with different content → 409 (no overwrite) {§6.4-conflict-409}; absent → proceed.
473
+ 1. `src_scheme.readEntry` → 404 if missing. {§copy-missing-source-404}
474
+ 2. `dst_scheme.readEntry` → conflict verdict, deferred until the written content is known (step 5): exists with identical content + tags → 304 (no-op, mirrors EDIT §edit) {§copy-noop-304}; exists with different content → 409 (no overwrite) {§copy-conflict-409}; absent → proceed.
431
475
  3. Mimetype compat — channels' mimetypes must be accepted by `dst_scheme.manifest.channels`. Mismatch → 415.
432
- 4. Tags: `signal` non-null replaces source tags {§6.4-signal-replaces-source-tags}; null/empty carries source tags {§6.4-no-signal-carries-source-tags}.
476
+ 4. Tags: `signal` non-null replaces source tags {§copy-signal-replaces-source-tags}; null/empty carries source tags {§copy-no-signal-carries-source-tags}.
433
477
  5. `dst_scheme.writeEntry({channels, tags})`.
434
478
 
435
- Returns 201 on success. Same- and cross-scheme COPY share the orchestrator. {§6.4-cross-scheme-copy}
479
+ Returns 201 on success. Same- and cross-scheme COPY share the orchestrator. {§copy-cross-scheme-copy}
436
480
 
437
- ### §6.5 MOVE (engine-orchestrated)
481
+ ### §move MOVE (engine-orchestrated)
438
482
 
439
483
  AST: `{ op: "MOVE", target (source), body: dest | null, signal: tags | null, lineMarker? }`.
440
484
 
441
- - **Relocation** (`body` non-null, resolvable dest): COPY (§6.4) + `src_scheme.deleteEntry` in one transaction. 201 on success. {§6.5-relocation-deletes-source} Cross-scheme same as same-scheme. {§6.5-cross-scheme-move} Missing source → 404. {§6.5-missing-source-404}
442
- - **MOVE never deletes.** A null body → 400 (a destination is required). {§6.5-null-body-400} `/dev/null` carries no special meaning — the MOVE→/dev/null delete idiom is retired; KILL is the canonical delete. {§6.5-dev-null-not-special}
485
+ - **Relocation** (`body` non-null, resolvable dest): COPY (§copy) + `src_scheme.deleteEntry` in one transaction. 201 on success. {§move-relocation-deletes-source} Cross-scheme same as same-scheme. {§move-cross-scheme-move} Missing source → 404. {§move-missing-source-404}
486
+ - **MOVE never deletes.** A null body → 400 (a destination is required). {§move-null-body-400} `/dev/null` carries no special meaning — the MOVE→/dev/null delete idiom is retired; KILL is the canonical delete. {§move-dev-null-not-special}
443
487
 
444
488
  Log history preserved — `log_entries` stores path tuple as text, not FK to `entries.id`.
445
489
 
446
- ### §6.6 FIND
490
+ ### §find FIND
447
491
 
448
492
  AST: `{ op: "FIND", target (scope), body: MatcherBody | null (predicate), signal: tags | null, lineMarker? }`.
449
493
 
450
- - Filters entries within scope (scheme + pathname prefix). {§6.6-scope-prefix-filter}
451
- - `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. {§6.6-glob-filter-on-content}
452
- - `signal` is a tag filter; entries match if they have ALL listed tags. {§6.6-tag-filter-and-semantics}
453
- - Session + scheme scoped — no cross-session/cross-scheme leakage. {§6.6-scoped-isolation}
494
+ - Filters entries within scope (scheme + pathname prefix). {§find-scope-prefix-filter}
495
+ - `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}
496
+ - `signal` is a tag filter; entries match if they have ALL listed tags. {§find-tag-filter-and-semantics}
497
+ - Session + scheme scoped — no cross-session/cross-scheme leakage. {§find-scoped-isolation}
454
498
  - Returns `{ status: 200, results: string }` (newline-separated matching paths, `text/plain`).
455
499
 
456
- ### §6.7 SEND
500
+ ### §send SEND
457
501
 
458
502
  AST: `{ op: "SEND", target: ParsedPath | null, body: SendBody | null, signal: number | null }`.
459
503
 
460
504
  - **Broadcast** (path null): terminal status (200/499) updates `loop.status` and ends loop. Other codes return `{status}` with no state change.
461
- - **Directed** (path non-null): routes to `scheme.send` per §3.5.
505
+ - **Directed** (path non-null): routes to `scheme.send` per §send-dispatch.
462
506
 
463
- ### §6.8 EXEC
507
+ ### §exec EXEC
464
508
 
465
509
  AST: `{ op: "EXEC", target (cwd), body: string | null (command), signal: string | null (runtime tag) }`.
466
510
 
467
- 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`. {§6.8-registry-resolves}
511
+ 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}
512
+
513
+ **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 an `exec://<runtime>/<loop>/<turn>/<seq>` entry (the executor is the URI authority; the coordinate that follows matches the op's log-row coordinate, e.g. `exec://sh/1/1/2`), returning `102 Processing` immediately. Channel state transitions (`active` → `closed`/`errored`) drive the model's view at subsequent turn boundaries (§channel-state). {§exec-host-proposes}
514
+
515
+ 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** in-process: no proposal, no human gate, no notification. The run is awaited synchronously and its channel content rides back as the EXEC result body the same turn — not streamed to the entry for a next-turn read. {§exec-readpure-inline}
468
516
 
469
- **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**: the run waits for a human gate, then spawns and writes stdout/stderr to channels of an `exec://<runtime>/<loop>/<turn>/<seq>` entry (the executor is the URI authority; the coordinate that follows matches the op's log-row coordinate, e.g. `exec://sh/1/1/2`), returning `102 Processing` immediately. Channel state transitions (`active` → `closed`/`errored`) drive the model's view at subsequent turn boundaries 5.6). {§6.8-host-proposes}
517
+ `SEND[499](exec://<runtime>/<loop>/<turn>/<seq>)` cancels in-flight subprocess via subscription registry's stored AbortControllerstream-control).
470
518
 
471
- 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** in-process: no proposal, no human gate, no notification. The run is awaited synchronously and its channel content rides back as the EXEC result body the same turn — not streamed to the entry for a next-turn read. {§6.8-readpure-inline}
519
+ ### §proposal The proposal lifecycle
472
520
 
473
- `SEND[499](exec://<runtime>/<loop>/<turn>/<seq>)` cancels in-flight subprocess via subscription registry's stored AbortController (§7.7).
521
+ A side-effecting op does not execute on dispatch — it **proposes**. The scheme returns **202** (an EXEC `host` runtime §exec, an EDIT to a member file §membership); the engine writes the log row `state='proposed'`, registers a waiter keyed by `logEntryId`, and **pauses `dispatch`** awaiting a resolution. The pause is internal to dispatch — the turn has already closed, so §grinder strike accounting sees the *resolved* status, never the 202. On accept the status becomes 200 and the scheme's effect runs. {§proposal-202-pauses}
522
+
523
+ **Resolution arrives four ways, one surface to the model:**
524
+ - **`loop.resolve`** (§methods) — a client's accept / reject / cancel.
525
+ - **Server-YOLO** (§dual-yolo) — an in-tree listener resolves `accept` in-process, same tick, no wire roundtrip.
526
+ - **noProposals** — an in-tree listener resolves `reject` (outcome `no_review_channel`).
527
+ - **Timeout** — `PLURNK_PROPOSAL_TIMEOUT_MS` (§operator-config) elapses with no resolution → the engine synthesizes `cancel` (outcome `timeout`), server-side, needing no client. {§proposal-timeout-cancels}
528
+
529
+ **The decision drives a one-way state transition** on `log_entries.state` (resolution is idempotent — `WHERE state='proposed'`, so a second resolution 404s):
530
+
531
+ | decision | state | `status_rx` | default outcome | effect |
532
+ |---|---|---|---|---|
533
+ | accept | `resolved` | 200 | — | runs the scheme's **`applyResolution`** — the real side effect (disk write, exec spawn). {§proposal-accept-applies} A failing apply (≥400) downgrades to reject (outcome `apply_failed`). |
534
+ | reject | `failed` | 400 | `rejected` | none — the action did not occur. {§proposal-reject-fails} |
535
+ | cancel | `cancelled` | 499 | `loop_aborted` | none — the loop is abandoning. {§proposal-cancel-aborts} |
536
+
537
+ A caller-supplied `outcome` overrides the default, but `outcome` is **forensics-only** — never in the model-facing `rx`. So a YOLO accept, a human reject, and a timeout are indistinguishable to the model: the action **occurred** (200) or it **didn't** (400/499), nothing about how it was administratively resolved (§telemetry).
538
+
539
+ **A proposed row is invisible until it resolves.** A `state='proposed'` / 202 row is withheld from `packet.system.log`; it surfaces only after resolution, carrying its terminal status — the model sees outcomes, never pending proposals. {§proposal-proposed-hidden}
474
540
 
475
541
  ---
476
542
 
477
- ## §7 Stream Model
543
+ ## §stream Stream Model
478
544
 
479
- Streams are static content from the engine's perspective — content arrives over time, channels grow, mimetype handlers render whatever's there at turn boundaries. No engine-level transaction abstraction; schemes own connection lifecycle. {§7-no-engine-transaction-abstraction}
545
+ Streams are static content from the engine's perspective — content arrives over time, channels grow, mimetype handlers render whatever's there at turn boundaries. No engine-level transaction abstraction; schemes own connection lifecycle. {§stream-no-engine-transaction-abstraction}
480
546
 
481
- ### §7.1 Subscriptions
547
+ ### §subscriptions Subscriptions
482
548
 
483
- 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. {§7.1-subscription-registry-routes-cancellation}
549
+ 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}
484
550
 
485
- Subscription registry is plurnk-service runtime state (its own SQLite table). Exists ONLY for cancellation routing. Channel state (§5.6) + log entries (§7.3) carry lifecycle.
551
+ 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.
486
552
 
487
- ### §7.2 Chunk accumulation
553
+ ### §chunk-accumulation Chunk accumulation
488
554
 
489
- SSE event types, WS message types, exec stdout/stderr each map to a named channel. Channel record (`ChannelContent`): `content`, `mimetype`, `tokens`. Active-connection state lives in the subscription registry, not on the channel. Chunks accumulate into the channel as they arrive — not buffered until close. {§7.2-chunks-accumulate}
555
+ SSE event types, WS message types, exec stdout/stderr each map to a named channel. Channel record (`ChannelContent`): `content`, `mimetype`, `tokens`. Active-connection state lives in the subscription registry, not on the channel. Chunks accumulate into the channel as they arrive — not buffered until close. {§chunk-accumulation-chunks-accumulate}
490
556
 
491
- ### §7.3 No per-chunk log rows
557
+ ### §no-chunk-rows No per-chunk log rows
492
558
 
493
- Channels are the source of truth for chunk content. Log captures lifecycle events only: open (102), graceful close (200), cancel (499), errors (5xx), scheme-significant transitions. {§7.3-log-captures-lifecycle-only}
559
+ Channels are the source of truth for chunk content. Log captures lifecycle events only: open (102), graceful close (200), cancel (499), errors (5xx), scheme-significant transitions. {§no-chunk-rows-log-captures-lifecycle-only}
494
560
 
495
561
  Model sees lifecycle events in `packet.system.log[]` per turn.
496
562
 
497
- ### §7.5 Deep slices on demand
563
+ ### §deep-slices Deep slices on demand
498
564
 
499
565
  `<<READ(sse://feed/x#data)<N-M>:…:READ` pulls a slice into a log row when the model wants a specific line-range of the stream.
500
566
 
501
- ### §7.7 SEND for stream control
567
+ ### §stream-control SEND for stream control
502
568
 
503
569
  - **Cancel:** `<<SEND[499](sse://feed/x)::SEND` — scheme tears down via AbortController.
504
570
  - **Write:** `<<SEND[200](wss://feed/x):body:SEND` — pipes body into active connection (WS, exec stdin, etc.).
505
571
 
506
- ### §7.8 Engine constraints
572
+ ### §stream-constraints Engine constraints
507
573
 
508
574
  ONE engine-level constraint: **100 MiB char-length cap per channel body**. `CHECK (length(content) <= 104857600)` on `entry_channels.content` (migrations/001_schema.sql). Violations → SQLITE_CONSTRAINT; action-entry captures rejection at status 500.
509
575
 
510
- All other limits are extrinsic — providers (request size, model context, fetch timeouts), schemes (per-call validation), mimetypes (render budgets). Engine does not throttle, batch, rate-limit, or cap anything else. {§7.8-engine-one-cap}
576
+ All other limits are extrinsic — providers (request size, model context, fetch timeouts), schemes (per-call validation), mimetypes (render budgets). Engine does not throttle, batch, rate-limit, or cap anything else. {§stream-constraints-engine-one-cap}
511
577
 
512
- ### §7.9 Live updates for clients (between turns)
578
+ ### §live-updates Live updates for clients (between turns)
513
579
 
514
- Daemon emits `stream/event` notifications (§13.6) when channel content changes; clients use them for live waterfalls without polling. {§7.9-stream-event-fires-on-chunk}
580
+ Daemon emits `stream/event` notifications (§notifications) when channel content changes; clients use them for live waterfalls without polling. {§live-updates-stream-event-fires-on-chunk}
515
581
 
516
582
  The model is NOT a stream/event consumer — turn-based only; sees whatever's in the channel at the next turn boundary.
517
583
 
518
584
  ---
519
585
 
520
- ## §8 Storage Model
586
+ ## §storage Storage Model
521
587
 
522
588
  SQLite (`node:sqlite`) with WAL mode and STRICT tables. Hand-written DDL; CI-aligned against grammar schemas.
523
589
 
524
- ### §8.1 DDL strategy
590
+ ### §ddl DDL strategy
525
591
 
526
592
  No generator. SQLite-optimal: STRICT (3.37+), `INTEGER PRIMARY KEY` aliasing, explicit `NOT NULL`, indexed query paths, deliberate FK `ON DELETE`/`ON UPDATE`, `WITHOUT ROWID` where access pattern warrants, generated columns, FTS5.
527
593
 
@@ -530,7 +596,7 @@ No generator. SQLite-optimal: STRICT (3.37+), `INTEGER PRIMARY KEY` aliasing, ex
530
596
  - **Schema-alignment test**: loads `@plurnk/plurnk-grammar/schema/*.json`, parses DDL via `node:sqlite` introspection, asserts every required schema field has a corresponding `NOT NULL` column. Grammar drift fails CI.
531
597
  - DDL = storage truth; JSON Schemas = wire truth. Tested-aligned, allowed to differ where ergonomics demand.
532
598
 
533
- ### §8.2 SQL/TS responsibility boundary
599
+ ### §sql-ts-boundary SQL/TS responsibility boundary
534
600
 
535
601
  **Lives in SQL:**
536
602
  - Render queries — log assembly + the manifest catalog.
@@ -541,10 +607,10 @@ No generator. SQLite-optimal: STRICT (3.37+), `INTEGER PRIMARY KEY` aliasing, ex
541
607
 
542
608
  **Lives in TS:**
543
609
  - Status-bubble rules (`turn.status` → `loop.status` → `run.status` → `session.status`). Engine UPDATEs explicitly; CHECK constraints enforce; triggers fight branching state machines.
544
- - Tokenization (provider-bound; hot-swap re-tokenizes per §14.2).
610
+ - Tokenization (provider-bound; hot-swap re-tokenizes per §tokenomics).
545
611
  - Provider dispatch + response normalization.
546
612
  - Scheme-handler invocation (connections, subprocesses, fetch).
547
- - Plugin loading (§9).
613
+ - Plugin loading (§plugin-discovery).
548
614
  - Stream AbortController lifecycle.
549
615
  - CLI + daemon.
550
616
 
@@ -552,7 +618,7 @@ When SQL becomes onerous for a specific case, retreat for that case and document
552
618
 
553
619
  ---
554
620
 
555
- ## §9 Plugin Discovery
621
+ ## §plugin-discovery Plugin Discovery
556
622
 
557
623
  Scoped-package scan with manifest field:
558
624
 
@@ -572,9 +638,9 @@ Env vars configure installed plugins; never declare existence. Filesystem is the
572
638
 
573
639
  ---
574
640
 
575
- ## §10 Bundled Set
641
+ ## §bundled-set Bundled Set
576
642
 
577
- Plugin discovery (§9) registers whatever's in `node_modules/@plurnk/*`.
643
+ Plugin discovery (§plugin-discovery) registers whatever's in `node_modules/@plurnk/*`.
578
644
 
579
645
  **Providers in-tree (`src/providers/`):** `Mock` (intg-only test fixture + worked example).
580
646
 
@@ -588,7 +654,7 @@ Plugin discovery (§9) registers whatever's in `node_modules/@plurnk/*`.
588
654
  | `Unknown.ts` | `@plurnk/plurnk-schemes-unknown` | Open questions / decomposition. |
589
655
  | `Skill.ts` | `@plurnk/plurnk-schemes-skill` | Skill docs; same shape as known. |
590
656
  | `Plurnk.ts` | may stay in-tree | `plurnk://prompt/<loop_id>` carries each loop's prompt. Model-origin writes to `plurnk://prompt/*` rejected in-handler. |
591
- | `Log.ts` | may stay in-tree | Read-only coordinate-addressed (`log://<L>/<T>/<S>`). Renders as JSON meta line in packet log; status ≥ 400 mirrors to `packet.user.telemetry.errors[]` (§15.1). |
657
+ | `Log.ts` | may stay in-tree | Read-only coordinate-addressed (`log://<L>/<T>/<S>`). Renders as JSON meta line in packet log; status ≥ 400 mirrors to `packet.user.telemetry.errors[]` (§telemetry). |
592
658
  | `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. |
593
659
  | `Exec.ts` | stays in-tree | Dispatches EXEC op to runtime executors registered via [plurnk-execs](https://github.com/plurnk/plurnk-execs). |
594
660
 
@@ -596,47 +662,48 @@ Plugin discovery (§9) registers whatever's in `node_modules/@plurnk/*`.
596
662
 
597
663
  ---
598
664
 
599
- ## §11 Grammar Dependency
665
+ ## §grammar Grammar Dependency
600
666
 
601
667
  `@plurnk/plurnk-grammar` is the contract. Authoritative; surface gaps via issue, adopt what lands. Don't redesign from this side.
602
668
 
603
- ### §11.1 What grammar provides
669
+ ### §grammar-provides What grammar provides
604
670
 
605
671
  - Parser (`PlurnkParser`, ANTLR4) — DSL text → `PlurnkStatement[]`.
606
672
  - AST types — exported TypeScript interfaces.
607
673
  - JSON schemas (`schema/*.json`, draft 2020-12) for every wire shape.
608
674
  - `plurnk.md` — canonical model-facing DSL description.
609
675
 
610
- ### §11.2 What plurnk-service tracks (NOT in grammar)
676
+ ### §service-tracks What plurnk-service tracks (NOT in grammar)
611
677
 
612
678
  - Channel state (`active`/`closed`/`errored`) — subscription registry, not on `ChannelContent`.
613
- - Backpressure caps — none (§7.8).
614
- - Stream cancel — `SEND[499]` (§7.7).
615
- - Delete — MOVE to `/dev/null` (§6.5); `SEND[410]` also deletes as a side-effect (§3.5).
679
+ - Backpressure caps — none (§stream-constraints).
680
+ - Stream cancel — `SEND[499]` (§stream-control).
681
+ - Delete — MOVE to `/dev/null` (§move); `SEND[410]` also deletes as a side-effect (§send-dispatch).
616
682
  - Per-loop flags — `loops.flags` JSON column; `yolo` end-to-end today, others scheduled.
617
- - Default-channel wire rendering — §5.5.
683
+ - Default-channel wire rendering — §channel-selection.
618
684
 
619
685
  ---
620
686
 
621
- ## §12 Operator Configuration
687
+ ## §operator-config Operator Configuration
622
688
 
623
689
  Env-var cascade: `.env.example` < `.env` < `.env.<config>` (via `--config=`) < shell < CLI flags. `bin/plurnk-service.ts` auto-loads `.env.example`; zero-setup boot.
624
690
 
625
- Model selection: separate alias cascade in `ProviderRegistry` (§2.3). `PLURNK_MODEL_<alias>=<provider>/<model-id>` declares; `PLURNK_MODEL=<alias>` selects. Aliases live in `.env`, not `.env.example` (operator-specific).
691
+ 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.example` (operator-specific).
626
692
 
627
693
  | Var | Default | Status | Purpose |
628
694
  |--------------------------------------|--------------------|------------|---------------------------------------------------------------|
629
695
  | `PLURNK_DB_PATH` | `./plurnk.db` | enforced | SQLite file path. |
630
696
  | `PLURNK_HOST` | `127.0.0.1` | enforced | Bind address for the daemon WebSocket. Local-only by default. |
631
697
  | `PLURNK_PORT` | `3044` | enforced | TCP port for the daemon WebSocket. |
632
- | `PLURNK_MAX_TURNS` | `999` | enforced | Per-loop turn cap (overridable per `loop.run` call). |
698
+ | `PLURNK_MAX_TURNS` | `-1` | enforced | Operator turn **ceiling** — `-1` = no cap; a positive value caps a per-call `loop.run({maxTurns})`. |
633
699
  | `PLURNK_MAX_COMMANDS` | `99` | enforced | Per-emission op cap. Overflow ops drop silently; one `max_commands_exceeded` telemetry entry surfaces on the next packet. |
634
700
  | `PLURNK_RPC_TIMEOUT` | `30000` | reserved | ms timeout for non-`longRunning` RPC handlers. Not yet enforced. |
635
701
  | `PLURNK_LOOP_TIMEOUT` | `86400000` | reserved | ms wall-clock budget for a single `loop.run`. Not yet enforced. |
636
- | `PLURNK_MAX_STRIKES` | `3` | enforced | Strike threshold + sudden-death lead time (§0.5). |
637
- | `PLURNK_MIN_CYCLES` | `3` | enforced | Min repetitions before cycle detection fires (§0.5). |
638
- | `PLURNK_MAX_CYCLE_PERIOD` | `4` | enforced | Max period length cycle detection examines (§0.5). |
702
+ | `PLURNK_MAX_STRIKES` | `3` | enforced | Strike threshold + sudden-death lead time (§engine-rails). |
703
+ | `PLURNK_MIN_CYCLES` | `3` | enforced | Min repetitions before cycle detection fires (§engine-rails). |
704
+ | `PLURNK_MAX_CYCLE_PERIOD` | `4` | enforced | Max period length cycle detection examines (§engine-rails). |
639
705
  | `PLURNK_PERSONA` | `persona.md` | enforced | Path to the default persona file. Tail of the persona cascade: loops.persona > runs.persona > sessions.persona > this file. |
706
+ | `PLURNK_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. |
640
707
  | `PLURNK_PROPOSAL_TIMEOUT_MS` | `300000` | enforced | ms wait for a proposed entry (status=202) to be resolved before timing out. |
641
708
  | `PLURNK_REASON` | `0` | enforced | Reasoning-token budget. 0 = disabled. Positive = budget in tokens; provider modules translate to wire format. |
642
709
  | `PLURNK_FETCH_TIMEOUT` | `600000` | enforced | Service-wide ms ceiling on any outbound request (providers, future http schemes). Module-specific overrides are allowed below the ceiling. |
@@ -645,6 +712,12 @@ Model selection: separate alias cascade in `ProviderRegistry` (§2.3). `PLURNK_M
645
712
 
646
713
  **enforced** = engine reads and acts on the value. **reserved** = shipped in `.env.example` (forward-spec) but no-op until wired.
647
714
 
715
+ **Two override semantics — ceiling vs default.** Which kind a var is determines what "override" means across the cascade:
716
+ - **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_GIT_ENABLED` (`=0` flatly denies git service-wide, §membership), `PLURNK_BUDGET_CEILING` (§tokenomics), `PLURNK_MAX_COMMANDS`, `PLURNK_MAX_STRIKES`, `PLURNK_FETCH_TIMEOUT` (module overrides allowed only *below* it), and `PLURNK_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.
717
+ - **Default** (explicit-wins) — a fallback the most-specific setter replaces freely: `PLURNK_MODEL` (a `loop.run({alias})` overrides it), `PLURNK_PERSONA` / `PLURNK_REQUIREMENTS` (the §persona persona cascade / per-call requirements), and the config-time vars (`HOST` / `PORT` / `DB_PATH`).
718
+
719
+ Enforcement is per-use-site — no central most-restrictive pass; each ceiling is checked where it bites. `PLURNK_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}
720
+
648
721
  Feature-flag bools use `process.env.X === "1"` exactly — never `=== "true"`.
649
722
 
650
723
  External plugins declare their own env vars in their own `.env.example`; service merges at boot via the cascade.
@@ -653,17 +726,17 @@ External plugins declare their own env vars in their own `.env.example`; service
653
726
 
654
727
  ---
655
728
 
656
- ## §13 RPC Surface
729
+ ## §rpc RPC Surface
657
730
 
658
- plurnk-service runs as a daemon. Clients (TUI/CLI/neovim/web/Telegram/etc.) drive it via self-describing RPC. This section is the wire — implementing a new client should require reading only §13.
731
+ plurnk-service runs as a daemon. Clients (TUI/CLI/neovim/web/Telegram/etc.) drive it via self-describing RPC. This section is the wire — implementing a new client should require reading only §rpc.
659
732
 
660
- ### §13.1 Transport
733
+ ### §transport Transport
661
734
 
662
735
  WebSocket (`ws` npm). One message per `ws.send`. UTF-8 JSON. One full-duplex connection per client. Bind: `PLURNK_HOST:PLURNK_PORT` (default `127.0.0.1:3044`).
663
736
 
664
737
  Out of scope for v0: auth, TLS, multiplexing. Local-loopback + filesystem permissions are the access control.
665
738
 
666
- ### §13.2 Protocol
739
+ ### §protocol Protocol
667
740
 
668
741
  JSON-RPC 2.0. Two message kinds:
669
742
 
@@ -672,7 +745,7 @@ JSON-RPC 2.0. Two message kinds:
672
745
 
673
746
  Success response: `{ "jsonrpc": "2.0", "id": …, "result": … }`. Failure: `{ "jsonrpc": "2.0", "id": …, "error": { "code": …, "message": …, "data": … } }`.
674
747
 
675
- ### §13.3 Method registration
748
+ ### §method-registration Method registration
676
749
 
677
750
  ```ts
678
751
  registry.register("loop.run", {
@@ -691,9 +764,9 @@ registry.register("loop.run", {
691
764
  - `description`: one-liner surfaced by `discover`.
692
765
  - `params`: `"type — meaning"` per param; `?` suffix = optional. Self-documenting, not enforced.
693
766
  - `requiresInit`: rejects until a session is attached.
694
- - `longRunning`: exempt from `PLURNK_RPC_TIMEOUT`. {§13.3-register}
767
+ - `longRunning`: exempt from `PLURNK_RPC_TIMEOUT`. {§method-registration-register}
695
768
 
696
- ### §13.4 Discovery
769
+ ### §discovery Discovery
697
770
 
698
771
  `discover` returns the catalog:
699
772
 
@@ -717,60 +790,60 @@ registry.register("loop.run", {
717
790
  }
718
791
  ```
719
792
 
720
- `capabilities` lists registered plug-ins by `(kind, name)`. Cold clients call `discover` first. No hardcoded method names or capability lists in any client. {§13.4-discover}
793
+ `capabilities` lists registered plug-ins by `(kind, name)`. Cold clients call `discover` first. No hardcoded method names or capability lists in any client. {§discovery-discover}
721
794
 
722
- ### §13.5 Core method set
795
+ ### §methods Core method set
723
796
 
724
797
  **Liveness + introspection**
725
798
 
726
799
  | Method | Params | Result | Notes |
727
800
  |------------|--------|--------|-------|
728
801
  | `ping` | none | `{}` | No init required. |
729
- | `discover` | none | catalog (§13.4) | No init required. |
802
+ | `discover` | none | catalog (§discovery) | No init required. |
730
803
 
731
804
  **Sessions**
732
805
 
733
806
  | Method | Params | Result | Notes |
734
807
  |------------------------|---------------------|-------------------|-------|
735
- | `session.create` | `name?: string`, `projectRoot?: string`, `persona?: string` | `{ id, name, runId, runName, projectRoot, persona }` | Creates new session + its first run; auto-name if unprovided. Returns the auto-created run's identity so clients skip the pending-dance ({§13.5-session-create}). Optional `projectRoot` pins the workspace (null/omitted = headless). Optional `persona` sets the session-level persona override. |
808
+ | `session.create` | `name?: string`, `projectRoot?: string`, `persona?: string` | `{ id, name, runId, runName, projectRoot, persona }` | 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 (null/omitted = headless). Optional `persona` sets the session-level persona override. |
736
809
  | `session.list` | none | `{ sessions: Session[] }` | Lists all sessions. |
737
- | `session.attach` | `id: number`, `runId?: number`, `runName?: string`, `persona?: 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. Optional `persona` sets run-level persona only when a NEW run is created. {§13.5-session-attach} |
810
+ | `session.attach` | `id: number`, `runId?: number`, `runName?: string`, `persona?: 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. Optional `persona` sets run-level persona only when a NEW run is created. {§methods-session-attach} |
738
811
  | `session.runs` | `id?: number` | `{ runs: Run[] }` | Lists runs in a session (defaults to attached session); most-recent first. |
739
812
  | `session.set_root` | `projectRoot: string \| null` | `{ projectRoot }` | Update the workspace pointer on the attached session. Null reverts to headless. |
740
813
  | `session.set_persona` | `persona: string \| null` | `{ persona }` | Update the session-level persona. Null clears the override (falls through to PLURNK_PERSONA file). |
741
- | `session.constrain` | `effect: "add" \| "ignore" \| "read-only"`, `glob: string` | `{ effect, glob }` | Add a workspace membership constraint (§14.3 overlay): `add` admits files git misses, `ignore` drops tracked matches, `read-only` admits for read but refuses edits. Immediate. |
814
+ | `session.constrain` | `effect: "add" \| "ignore" \| "read-only"`, `glob: string` | `{ effect, glob }` | Add a workspace membership constraint (§membership overlay): `add` admits files git misses, `ignore` drops tracked matches, `read-only` admits for read but refuses edits. Immediate. |
742
815
  | `session.unconstrain` | `effect: "add" \| "ignore" \| "read-only"`, `glob: string` | `{ effect, glob }` | Remove a membership constraint — the inverse of `session.constrain`. Immediate. |
743
816
  | `session.constraints` | none | `{ constraints }` | List the attached session's membership constraints. |
744
817
 
745
- **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. {§13.5-rebind}
818
+ **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}
746
819
 
747
- **Auto-envelope.** Clients calling a `requiresInit: true` method without first attaching get auto-created session → run → client loop. Records persist normally; auto-created ≠ auto-deleted. Cleanup is a future `session.delete` / `session.archive` endpoint. {§13.5-auto-envelope}
820
+ **Auto-envelope.** Clients calling a `requiresInit: true` method without first attaching get auto-created session → run → client loop. Records persist normally; auto-created ≠ auto-deleted. Cleanup is a future `session.delete` / `session.archive` endpoint. {§methods-auto-envelope}
748
821
 
749
- **Reserved run names.** `plurnk` is reserved for the runtime actor (§0.4). `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. {§13.5-run-name-reserved}
822
+ **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}
750
823
 
751
824
  **Loops (model-driven)**
752
825
 
753
826
  | Method | Params | Result | Notes |
754
827
  |-------------------|-------------------------------------|------------------------|-------|
755
- | `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `flags?: LoopFlags`, `persona?: string` | `{ loopId, turnIds, finalStatus, hitMaxTurns, reason }` | Model-driven loop. Optional `alias` overrides the boot-time `PLURNK_MODEL`. Optional `flags` carries per-loop flags (currently `{yolo?: boolean}`; more arrive as wired — see §0.5). Optional `persona` sets the loop-level persona (highest precedence in the cascade). Streams `log/entry` and `loop/proposal` notifications during. `longRunning: true`. {§13.5-loop-run} |
828
+ | `loop.run` | `prompt: string`, `maxTurns?: number`, `alias?: string`, `flags?: LoopFlags`, `persona?: string` | `{ loopId, turnIds, finalStatus, hitMaxTurns, reason }` | Model-driven loop. Optional `alias` overrides the boot-time `PLURNK_MODEL`. Optional `flags` carries per-loop flags (currently `{yolo?: boolean}`; more arrive as wired — see §engine-rails). Optional `persona` sets the loop-level persona (highest precedence in the cascade). Streams `log/entry` and `loop/proposal` notifications during. `longRunning: true`. {§methods-loop-run} |
756
829
  | `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. |
757
- | `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`. {§13.5-loop-cancel} |
830
+ | `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`. {§methods-loop-cancel} |
758
831
  | `providers.list` | none | `{ aliases: ProviderAlias[] }` | Lists configured `PLURNK_MODEL_<alias>` entries with `{alias, provider, model, active}`. Clients use to populate model-selection UI. |
759
832
 
760
833
  **Reads**
761
834
 
762
835
  | Method | Params | Result | Notes |
763
836
  |---------------|-------------------------------------|------------------------|-------|
764
- | `entry.read` | `target: string` | `{ status, entry }` | Read the full entry shape (channels + tags + metadata) at the given URI. {§13.5-entry-read} |
765
- | `log.read` | `loopId?: number`, … | `{ entries: LogEntry[] }` | Read recent log entries from the attached session, optionally filtered by loop. {§13.5-log-read} |
837
+ | `entry.read` | `target: string` | `{ status, entry }` | Read the full entry shape (channels + tags + metadata) at the given URI. {§methods-entry-read} |
838
+ | `log.read` | `loopId?: number`, … | `{ entries: LogEntry[] }` | Read recent log entries from the attached session, optionally filtered by loop. {§methods-log-read} |
766
839
 
767
- **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. {§13.5-log-coordinate}
840
+ **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}
768
841
 
769
842
  **DSL operations (client-driven, mirror grammar)**
770
843
 
771
- Per the **Speak in DSL, not plumbing** rule (AGENTS.md): `op.*` methods construct DSL statements internally and dispatch through `Engine.dispatch`. {§13.5-op-mirror} Param shapes are ergonomic (semantic names, not HEREDOC slots); semantics are the DSL's.
844
+ Per the **Speak in DSL, not plumbing** rule (AGENTS.md): `op.*` methods construct DSL statements internally and dispatch through `Engine.dispatch`. {§methods-op-mirror} Param shapes are ergonomic (semantic names, not HEREDOC slots); semantics are the DSL's.
772
845
 
773
- Each `op.*` call creates a turn in the connection's client loop (§13.7), dispatches, fires `log/entry`, returns the dispatch result.
846
+ Each `op.*` call creates a turn in the connection's client loop (§connection-lifecycle), dispatches, fires `log/entry`, returns the dispatch result.
774
847
 
775
848
  Naming: `target` = URI the op acts on; `scope` for FIND; `source`/`destination` for COPY/MOVE; `recipient` for SEND (or null = broadcast); `cwd` for EXEC. `path` is reserved for *identity* — never an RPC operand.
776
849
 
@@ -792,23 +865,23 @@ All `op.*` return `{ status, ...op-specific }`. All `requiresInit: true`. None `
792
865
 
793
866
  Future: `subscription.list`, `subscription.cancel` (the latter is `op.send({status: 499, recipient})` today).
794
867
 
795
- ### §13.6 Notifications
868
+ ### §notifications Notifications
796
869
 
797
870
  Server-initiated events on the same WebSocket.
798
871
 
799
872
  | Notification | Params | When fired |
800
873
  |--------------------|-------------------------------------|------------|
801
- | `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§13.6-log-entry-notify} |
874
+ | `log/entry` | `{ entry: LogEntry }` | Every `log_entries` write. {§notifications-log-entry-notify} |
802
875
  | `loop/terminated` | `{ loopId, finalStatus, hitMaxTurns }` | Loop reaches terminal status. |
803
876
  | `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_PROPOSAL_TIMEOUT_MS` fires). |
804
877
  | `session/created` | `{ id, name, projectRoot, persona }` | Any client creates a session. |
805
- | `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§13.6-stream-event-on-channel-change} |
806
- | `stream/concluded` | `{ entryId, target, subscriptionId, scheme, closeStatus, summary, wakeAction, wakeLoopId? }` | A streaming subscription closed (subprocess finished / errored / cancelled). `wakeAction` says whether the daemon opened a fresh loop to surface the conclusion to the model. {§13.6-stream-concluded} |
807
- | `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. {§13.6-telemetry-event} |
878
+ | `stream/event` | `{ entryId, channel, state, contentLength }` | Channel content grows or state transitions. {§notifications-stream-event-on-channel-change} |
879
+ | `stream/concluded` | `{ entryId, target, subscriptionId, scheme, closeStatus, summary, wakeAction, wakeLoopId? }` | A streaming subscription closed (subprocess finished / errored / cancelled). `wakeAction` says whether the daemon opened a fresh loop to surface the conclusion to the model. {§notifications-stream-concluded} |
880
+ | `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} |
808
881
 
809
- `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 ({§13.6-envelope-carries-sessionid}); the broadcast stays session-scoped too.
882
+ `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.
810
883
 
811
- ### §13.7 Connection lifecycle
884
+ ### §connection-lifecycle Connection lifecycle
812
885
 
813
886
  ```
814
887
  [client] [daemon]
@@ -837,11 +910,11 @@ Server-initiated events on the same WebSocket.
837
910
  | (daemon closes the client loop; session keeps)|
838
911
  ```
839
912
 
840
- **The client's run.** A client connection is an actor (§14.8); 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.
913
+ **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.
841
914
 
842
- `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 (§14.8); the packet renders only the model's run, so the client's ops are structurally absent from it — no origin filter (§14.7-isolation). *Migration:* the daemon today opens both loops in the connection's one run (the conflation §14.8 corrects); the build gives the client and the model their separate runs.
915
+ `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). *Migration:* the daemon today opens both loops in the connection's one run (the conflation §machine-processes corrects); the build gives the client and the model their separate runs.
843
916
 
844
- ### §13.8 Errors
917
+ ### §errors Errors
845
918
 
846
919
  Standard JSON-RPC codes:
847
920
 
@@ -866,19 +939,19 @@ Plurnk-specific (`-32000` to `-32099`):
866
939
  | -32006 | Mimetype unavailable |
867
940
  | -32007 | Timeout |
868
941
 
869
- Error responses MAY include `data: {…}` with structured context (404'd path, timed-out method, etc.). {§13.8-error-codes}
942
+ Error responses MAY include `data: {…}` with structured context (404'd path, timed-out method, etc.). {§errors-error-codes}
870
943
 
871
- ### §13.9 Versioning
944
+ ### §versioning Versioning
872
945
 
873
946
  Pre-stabilization. Clients track HEAD. No semver until the interface is worth committing to.
874
947
 
875
948
  ---
876
949
 
877
- ## §14 Architectural decisions
950
+ ## §decisions Architectural decisions
878
951
 
879
952
  Each entry: question, answer, rationale, migration path.
880
953
 
881
- ### §14.1 Packet assembly: engine-direct, not filter-chain
954
+ ### §packet-assembly Packet assembly: engine-direct, not filter-chain
882
955
 
883
956
  **Question.** Rummy uses priority-ordered filter chains for packet assembly. Plurnk assembles directly in `Engine.#buildRequestPacket` (`#buildLog` + the materialized manifest catalog).
884
957
 
@@ -888,7 +961,7 @@ Each entry: question, answer, rationale, migration path.
888
961
 
889
962
  **Migration path.** If a plugin needs to inject a packet section, grow a single `packet.augment` hook called after `#buildRequestPacket`; plugins return system/user augmentation objects merged into the packet. Additive — engine-direct base stays.
890
963
 
891
- ### §14.2 Tokenomics: real provider tokens, render-weight budget, per-scheme balance
964
+ ### §tokenomics Tokenomics: real provider tokens, render-weight budget, per-scheme balance
892
965
 
893
966
  **Question.** How does plurnk track token costs accurately enough to ground the model's OPEN/FOLD/compose decisions? Accuracy is the whole game — a budget that smells wrong is one the model stops trusting and curating against.
894
967
 
@@ -899,12 +972,12 @@ Each entry: question, answer, rationale, migration path.
899
972
 
900
973
  **Built.**
901
974
 
902
- - **Provider tokens, stored at write.** `provider.countTokens` is the source of truth; `entry_channels.tokens` (via `_entry-crud`) and `log_entries.tokens` (via `Engine.#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. {§14.2-tokens-stored-at-write}
903
- - **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. {§14.2-render-weight-budget}
904
- - **Per-scheme balance.** A markdown table groups the model's context by scheme — render-weight `tokens` per scheme — anchored `repo, known, unknown, log`, tail sorted by tokens. The model sees at a glance what's eating its window. {§14.2-per-scheme-balance}
905
- - **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. {§14.2-context-percent}
975
+ - **Provider tokens, stored at write.** `provider.countTokens` is the source of truth; `entry_channels.tokens` (via `_entry-crud`) and `log_entries.tokens` (via `Engine.#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}
976
+ - **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}
977
+ - **Per-scheme balance.** A markdown table groups the model's context by scheme — render-weight `tokens` per scheme — anchored `repo, known, unknown, log`, tail sorted by tokens. The model sees at a glance what's eating its window. {§tokenomics-per-scheme-balance}
978
+ - **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}
906
979
  - **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.
907
- - **Over-budget is honest.** When usage exceeds the ceiling, `free` floors at 0 and the percent passes 100 — the readout shows the overshoot rather than a negative free, so the model knows it's over and curates down. {§14.2-over-budget-floor}
980
+ - **Over-budget is honest.** When usage exceeds the ceiling, `free` floors at 0 and the percent passes 100 — the readout shows the overshoot rather than a negative free, so the model knows it's over and curates down. {§tokenomics-over-budget-floor}
908
981
 
909
982
  **Rejected / obviated.**
910
983
 
@@ -915,50 +988,50 @@ Each entry: question, answer, rationale, migration path.
915
988
 
916
989
  **Migration path.** None on cost — SQLite, JS, and a local tokenizer are negligible against the model's token budget, the only thing worth economizing. The fallback divisor is a correctness tripwire (no provider tokenizer wired), not a performance retreat. Schema unchanged.
917
990
 
918
- ### §14.3 Workspace identity, membership, disk co-location
991
+ ### §membership Workspace identity, membership, disk co-location
919
992
 
920
993
  **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?
921
994
 
922
995
  **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).
923
996
 
924
- **Built — git-substrate membership.** {§14.3-git-membership}
997
+ **Built — git-substrate membership.** {§membership-git-membership}
925
998
 
926
999
  - **Identity on the session.** No `projects` table; `sessions.project_root TEXT` (nullable = headless). `entries.scope` unchanged (`∈ {'agent','session'}`). Workspace = session; no users/auth/multi-tenant.
927
1000
  - **git-ls-files membership.** git present → tracked files (`git ls-files`) are members with no explicit `add` — channel-less markers, disk is truth. git absent → no fs-walk (non-git/headless get no substrate membership).
928
1001
  - **EMI eager + exhaustive.** Materializes *every* active git member at prompt-composition, re-reading disk so a divergent member reflects current content. git's `ls-files` is the whole membership bound — the engine adds no relevance pass of its own. Allowing git *is* the curation.
929
- - **Membership-gated edits.** {§14.3-edit-membership-gate} EDIT is bounded by membership exactly as READ is. An existing **member** is read from disk before diffing, so its baseline is real content, never empty — silent overwrite is structurally prevented. 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.
1002
+ - **Membership-gated edits.** {§membership-edit-membership-gate} EDIT is bounded by membership exactly as READ is. An existing **member** is read from disk before diffing, so its baseline is real content, never empty — silent overwrite is structurally prevented. 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.
930
1003
 
931
- - **Constraint overlay — `ignore`.** {§14.3-constraint-ignore} A `session_constraints` table (effect ∈ {add, ignore, read-only}, glob) is the client's supersede over git. `ignore` drops tracked matches: resolution computes `git ls-files − ignore` (`node:path.matchesGlob`) and **reconciles** — registers the desired, un-registers any git-origin member no longer desired (untracked or newly ignored) — 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.
932
- - **Constraint overlay — `read-only`.** {§14.3-constraint-readonly} A `read-only` glob keeps a matching member readable but refuses `File.edit` — 403'd at the membership check, before any diff. A file admitted for reading without granting writes. (Admitting an *untracked* file as read-only rides on `add`'s scan.)
933
- - **Constraint overlay — `add`.** {§14.3-constraint-add} `add` globs admit members git misses: a targeted, client-dictated `node:fs` glob scan enumerates untracked matches (files only), registered with 'constraint' origin and reconciled like git members. Enumerated, so the manifest stays exhaustive. git-absent, `add` is the *sole* membership source — the whole mechanism without git.
934
- - **EMI divergence signal.** {§14.3-emi-divergence-signal} When git membership re-reads a member whose disk content changed out-of-band, the build-time delta detector (§14.5) 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 accepted edits advance the run's watermark, so they're never mis-attributed as external divergence.
1004
+ - **Constraint overlay — `ignore`.** {§membership-constraint-ignore} A `session_constraints` table (effect ∈ {add, ignore, read-only}, glob) is the client's supersede over git. `ignore` drops tracked matches: resolution computes `git ls-files − ignore` (`node:path.matchesGlob`) and **reconciles** — registers the desired, un-registers any git-origin member no longer desired (untracked or newly ignored) — 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.
1005
+ - **Constraint overlay — `read-only`.** {§membership-constraint-readonly} A `read-only` glob keeps a matching member readable but refuses `File.edit` — 403'd at the membership check, before any diff. A file admitted for reading without granting writes. (Admitting an *untracked* file as read-only rides on `add`'s scan.)
1006
+ - **Constraint overlay — `add`.** {§membership-constraint-add} `add` globs admit members git misses: a targeted, client-dictated `node:fs` glob scan enumerates untracked matches (files only), registered with 'constraint' origin and reconciled like git members. Enumerated, so the manifest stays exhaustive. git-absent, `add` is the *sole* membership source — the whole mechanism without git.
1007
+ - **EMI divergence signal.** {§membership-emi-divergence-signal} When git membership re-reads a member whose disk content changed out-of-band, the build-time 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 accepted edits advance the run's watermark, so they're never mis-attributed as external divergence.
935
1008
 
936
1009
  **Rationale.** No users/tenants. Session is the right scope unit. git+constraints membership keeps the model out of fs-walk territory — and *is* the curation, outsourced: git bounds membership by tracking, the client supersedes by constraint, the model curates its view by READ/FOLD. The engine curates nothing. EMI re-reads every member each turn; the full-repo cost is git's to bound (what it tracks) and the client's to bound (`ignore`), never the engine's to bound by relevance.
937
1010
 
938
1011
  **Migration path.** Tenancy / cross-session shared workspaces require a `workspaces` table joining sessions to workspace id, with constraints lifted off `session_constraints`. Disk co-location semantics unchanged.
939
1012
 
940
- ### §14.4 Budget enforcement: the grinder
1013
+ ### §grinder Budget enforcement: the grinder
941
1014
 
942
- **Question.** §14.2 surfaces the budget honestly and the model curates against `tokensFree` — almost always enough. Two states defeat self-regulation, neither the model's doing: a jumbo prompt (the turn-0 environment), and an unexpectedly large read. (A jumbo repo is no longer its own case — with no index nothing auto-renders the repo; it surfaces only as a large `manifest.json` READ, which the model chunks like any big read.) What enforces the ceiling when the signal isn't enough?
1015
+ **Question.** §tokenomics surfaces the budget honestly and the model curates against `tokensFree` — almost always enough. Two states defeat self-regulation, neither the model's doing: a jumbo prompt (the turn-0 environment), and an unexpectedly large read. (A jumbo repo is no longer its own case — with no index nothing auto-renders the repo; it surfaces only as a large `manifest.json` READ, which the model chunks like any big read.) What enforces the ceiling when the signal isn't enough?
943
1016
 
944
- **Decision — a pre-LLM grinder, fired only on actual overflow.** In `Engine.runTurn`, after the packet is assembled (`#buildRequestPacket`) and before `provider.generate`, the assembled render-weight (§14.2) is measured against the ceiling. At or under → the packet ships untouched; the grinder never trims speculatively or "helpfully." {§14.4-overflow-only} On overflow it reverts the prior turn, then hard-stops if that isn't enough:
1017
+ **Decision — a pre-LLM grinder, fired only on actual overflow.** In `Engine.runTurn`, after the packet is assembled (`#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 reverts the prior turn, then hard-stops if that isn't enough:
945
1018
 
946
- - **Prior-turn rollback.** The immediately-prior turn's log entries — the latest emissions, the ones that pushed the packet over — are folded (`indexed=0`, the same flag the model's own FOLD uses); the prior turn fit by induction, so reverting it usually lands back under. Folded, not deleted: rows and bodies persist and are re-OPENable, so log *history* is preserved while the render collapses to coordinates. {§14.4-layer1-rollback}
947
- - **Hard stop.** If the packet still overflows after the prior-turn rollback, the loop abandons at 499 (`engine_loop_cancel`) — the path `maxTurns` and the strike threshold already use. No further passes. {§14.4-hard-413-abort}
1019
+ - **Prior-turn rollback.** The immediately-prior turn's log entries — the latest emissions, the ones that pushed the packet over — are folded (`indexed=0`, the same flag the model's own FOLD uses); the prior turn fit by induction, so reverting it usually lands back under. 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}
1020
+ - **Hard stop.** If the packet still overflows after the prior-turn rollback, the loop abandons at 499 (`engine_loop_cancel`) — the path `maxTurns` and the strike threshold already use. No further passes. {§grinder-hard-413-abort}
948
1021
 
949
- **Strike coupling.** A grinder fire bumps the engine's `turnErrors` — the same internal counter cycle detection feeds — so an overflow counts toward the strike streak that ends a runaway loop at 499. This is the pressure that keeps self-curation the path of least resistance. {§14.4-strike-coupling} **Turn 0/1 is exempt:** the first turn's overflow precedes any model action — it's the environment, not the model — so it never strikes. {§14.4-soft-turn-0-1}
1022
+ **Strike coupling.** A grinder fire bumps the engine's `turnErrors` — the same internal counter cycle detection feeds — so an overflow counts toward the strike streak that ends a runaway loop at 499. This is the pressure that keeps self-curation the path of least resistance. {§grinder-strike-coupling} **Turn 0/1 is exempt:** the first turn's overflow precedes any model action — it's the environment, not the model — so it never strikes. {§grinder-soft-turn-0-1}
950
1023
 
951
- **What the model sees.** A `budget_overflow` telemetry event (§15.1), in the model's own terms: which of its entries left the window, by scheme. No mechanism vocabulary — no "layer," no "grinder," no "reclaim" — and no advice. The engine reports *what happened to the model's world*; the per-scheme budget table (§14.2) is the diagnostic surface, and the model — which can see what changed in its repo, its reads, its turn — diagnoses the cause the engine can't attribute. {§14.4-event-model-terms} Per the gamification policy (§15.1), the *strike* the overflow triggers stays engine-internal; the model sees the hidden entries, never the accounting.
1024
+ **What the model sees.** A `budget_overflow` telemetry event (§telemetry), in the model's own terms: which of its entries left the window, by scheme. No mechanism vocabulary — no "layer," no "grinder," no "reclaim" — and no advice. The engine reports *what happened to the model's world*; the per-scheme budget table (§tokenomics) is the diagnostic surface, and the model — which can see what changed in its repo, its reads, its turn — diagnoses the cause the engine can't attribute. {§grinder-event-model-terms} Per the gamification policy (§telemetry), the *strike* the overflow triggers stays engine-internal; the model sees the hidden entries, never the accounting.
952
1025
 
953
- **Rationale.** The model owns curation (§14.2); the grinder is the exceptional backstop. It only *hides* — reversibly — the prior turn's render; nothing is deleted, so the model can OPEN it back and log history stays intact. Rummy's §1316 spec described clearing log *bodies*, but its code instead hid the prior turn whole — because body-clearing is destructive (it deletes the read result) and bespoke. The code was the lesson; plurnk follows it.
1026
+ **Rationale.** The model owns curation (§tokenomics); the grinder is the exceptional backstop. It only *hides* — reversibly — the prior turn's render; nothing is deleted, so the model can OPEN it back and log history stays intact. Rummy's §1316 spec described clearing log *bodies*, but its code instead hid the prior turn whole — because body-clearing is destructive (it deletes the read result) and bespoke. The code was the lesson; plurnk follows it.
954
1027
 
955
1028
  **Migration path.** None on mechanism. Speculative or non-overflow trimming is a different feature, deliberately excluded — the grinder fires only in response to actual overflow.
956
1029
 
957
- ### §14.5 The environment delta: what changed since the model last looked
1030
+ ### §env-delta The environment delta: what changed since the model last looked
958
1031
 
959
- **Question.** The manifest (§15) is a live directory of what *exists*, re-derived each turn — but it carries *state*, not *events*. When a session entry changes out-of-band between turns — an exec stream grows, a sibling run edits a shared entry, a tracked file diverges on disk (§14.3) — the model's prior READ is now stale, and the manifest's new line count is a fact it must *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?
1032
+ **Question.** The manifest (§packet) is a live directory of what *exists*, re-derived each turn — but it carries *state*, not *events*. When a session entry changes out-of-band between turns — an exec stream grows, a sibling run edits a shared entry, 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 must *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?
960
1033
 
961
- **Decision — a per-run delta of plurnk-authored EDITs.** When a session entry changes out-of-band, the engine records it the way the model records its *own* edits: a `log_entries` row, op=`EDIT`, `origin=plurnk`, carrying the new `source` attribution (the cause). The body is exactly an EDIT's body — **the edited area as it looks now, line-numbered, with a couple lines of context** (the result-rendering all EDITs share, §14.6). The set is **exhaustive and unranked** — every change, no relevance order — but **not content-free**: showing the edited region of a change that *happened* is a faithful record, not the index regrowing. The engine may inform (the §14.3 EMI divergence is the precedent), never rank or select what the model retains; the one line it must never cross is *ranking*. Volume is the model's to manage by FOLD (and the grinder's under budget), not the engine's to manage by gutting the payload.
1034
+ **Decision — a per-run delta of plurnk-authored EDITs.** When a session entry changes out-of-band, the engine records it the way the model records its *own* edits: a `log_entries` row, op=`EDIT`, `origin=plurnk`, carrying the new `source` attribution (the cause). The body is exactly an EDIT's body — **the edited area as it looks now, line-numbered, with a couple lines of context** (the result-rendering all EDITs share, §edit-result-render). The set is **exhaustive and unranked** — every change, no relevance order — but **not content-free**: showing the edited region of a change that *happened* is a faithful record, not the index regrowing. The engine may inform (the §membership EMI divergence is the precedent), never rank or select what the model retains; the one line it must never cross is *ranking*. Volume is the model's to manage by FOLD (and the grinder's under budget), not the engine's to manage by gutting the payload.
962
1035
 
963
1036
  **Form — a log entry, `origin=plurnk`, the change translated to DSL.** A delta is a `log_entries` row: an **`EDIT`** ("an EDIT happened to X"), `origin=plurnk`, carrying the new **`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). `source` renders as `run="<id>"` / `run="file"` in the meta line, **omitted when the cause is the owning run itself**. It is a third attribution axis, distinct from `run_id` (whose log owns the row) and `origin` (the actor *type*).
964
1037
 
@@ -966,67 +1039,42 @@ Each entry: question, answer, rationale, migration path.
966
1039
 
967
1040
  **Coalesce per `(entry, source)`.** Net the magnitude, keep the cause: three edits to one entry by one run collapse to a single `+N`; the same entry touched by a run *and* the fs stays two deltas. Provenance is the delta's reason to exist, so it is the one thing coalescing never erases.
968
1041
 
969
- **Detection — announced vs ambient.** A run *knows* when it writes, so run-caused changes are **broadcast** (the existing `stream/event`, §13.6 — already session-scoped and carrying its `runId`) and **queued** per sibling run, drained into deltas at that run's next turn. Nothing announces an external disk edit, so ambient fs changes are **scooped at pre-turn** by the §14.3 EMI scan. Attribution falls out of detection: the broadcast carries the run; the scan attributes to the scheme.
1042
+ **Detection — announced vs ambient.** A run *knows* when it writes, so run-caused changes are **broadcast** (the existing `stream/event`, §notifications — already session-scoped and carrying its `runId`) and **queued** per sibling run, drained into deltas at that run's next turn. Nothing announces an external disk edit, so ambient fs changes are **scooped at pre-turn** by the §membership EMI scan. Attribution falls out of detection: the broadcast carries the run; the scan attributes to the scheme.
970
1043
 
971
1044
  **Rationale.** "The model knows its world moved" becomes a property of *reading reality at build*, not of *remembering to emit* — 100% coverage by construction. The engine records each change faithfully — as the EDIT it was, showing its result — and hands the model the wheel; it never ranks, selects, or folds on the model's behalf. The model FOLDs what it doesn't need; the grinder folds under budget.
972
1045
 
973
1046
  **Migration path.** Additive. The `source` column defaults null — existing rows and the current render are unaffected; materialization and the broadcast/EMI detection layer on without touching the op surface.
974
1047
 
975
- ### §14.6 EDIT log rows render their result, not their input
1048
+ ### §edit-result-render EDIT log rows render their result, not their input
976
1049
 
977
- **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 (§14.5) has no input statement at all. What should an EDIT row's body be?
1050
+ **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?
978
1051
 
979
- **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 (§14.5) identically. The meta line still carries op + target, so "I EDITed X" stays legible; the body says "and here's X now."
1052
+ **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."
980
1053
 
981
1054
  **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.
982
1055
 
983
1056
  **Migration path.** Changes what EDIT rows *show* (input → output); the op surface and EDIT's behaviour are unchanged. Tests asserting the input-heredoc render move to the resulting-span render.
984
1057
 
985
- ### §14.7 The actor boundary: isolation by run, two doors, self-hosting
986
-
987
- **Question.** A session holds many runs — model, client, plurnk (§0.1, §0.4) — 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?
988
-
989
- **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 (§15). A run cannot see another's log: isolation is *structural*, a consequence of "a run owns its log entries" (§0.1) and "one packet, one run," never an `origin` filter at render time. `origin` (§0.4) is **attribution** — the delta's provenance (§14.5) — never read to hide a row. {§14.7-isolation} {§14.7-origin-not-filter}
990
-
991
- **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:
992
- - the **environment door** — a write to a *shared entry* surfaces to every run sharing it as a folded, attributed delta (§14.5). *State.*
993
- - the **voice door** — an **inject** delivers a turn into a *specific* run's log; `btw` is the user's mid-loop inject. *Message.*
994
-
995
- {§14.7-two-doors}
1058
+ ### §dual-yolo Dual-YOLO: server- and client-side auto-accept
996
1059
 
997
- **Wild westno mutual exclusion.** Runs share the manifest without locks. Coordination is cooperative (tags + the shared workspace convention) and softly fenced (the §14.3 `read-only` overlay, a session policy, bounds every run's writable surface uniformly — §14.8); a conflict *surfaces* as a delta rather than being prevented. Inform, never override. {§14.7-no-mutex}
1060
+ **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?
998
1061
 
999
- **Passive wake.** An idle run wakes on exactly two events a prompt injected into it (voice; user or system) or a stream-status transition it subscribes to (§5.6). A delta never wakes a run; it queues and drains at the next turn one of those produces (§14.5). {§14.7-passive-wake}
1062
+ **Decision two distinct, complementary mechanisms.** Auto-accept lives at two layers that never substitute for each other:
1000
1063
 
1001
- **Self-hostingthe runtime is an actor, not a back channel.** Runtime-initiated work (fs reconciliation §14.3, 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 §14.4, the fs-watch); everything expressible as ops on session entries is a run doing ops, through the same `op.*` surface (§13.5) the service offers clients. Dogfooding is the architecture, not a test mode. {§14.7-self-hosting}
1064
+ - **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.
1065
+ - **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.
1002
1066
 
1003
- **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`. What remains is *repatriation* the inline plurnk dispatches (the §14.5 materialization, today bolted into the model's loop) move into ephemeral plurnk runs via a headless spawn primitive mirroring `_dispatchAsClient`. Once every mutation is a run-scoped action, SAVEPOINT-nested rollback (run → loop → turn → action) falls out uniformly.
1067
+ **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}
1004
1068
 
1005
- ### §14.8 The machine and its processes: session, run, fork
1069
+ **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.
1006
1070
 
1007
- **Question.** §14.7 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 view of the workspace would live — grow subtle, then metastasize. Drawn once, they vanish.
1008
-
1009
- **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 `plurnk://manifest.json` (§15) — under one membership overlay (§14.3). Exactly one filesystem and one overlay per session; neither is per-run. A **run** is a process whose entire private memory is its **log** (§0.1) — its loops, turns, and rows, each row carrying its own content, attribution (`origin`/`source`, §14.5), and fold-state (`indexed`). A run owns **no entries** and **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*.
1010
-
1011
- **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. {§14.8-one-filesystem}
1012
-
1013
- **One overlay.** Membership — `git ls-files ∪ add − ignore` with read-only (§14.3) — 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. {§14.8-one-overlay}
1014
-
1015
- **A run is its log — and nothing beside.** The run-private state is the log and only the log. *What I am looking at* (OPEN/FOLD) is `log_entries.indexed`, a bit on the run's own rows, toggled by ordinary `log://` ops — not a second store, and never membership (§6.3). *What I last saw* needs no shadow either: a run learns its world moved through log entries (§14.5) — 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. The log is the whole of a run's memory. {§14.8-run-is-its-log}
1016
-
1017
- **Fork — copy the log, share the world.** A fork is a new run in the *same* session (`runs.parent_run_id`, §0.1), taken at a savepoint. It copies the **log** — the rows, their fold-state riding along — so the branch inherits everything the parent observed (§14.5 makes a run's timeline self-contained for exactly this) and diverges freely after. {§14.8-fork-copies-the-log} It shares the **world** — the one filesystem, the one overlay — live and uncopied, because the run never owned it. {§14.8-fork-shares-the-world}
1018
-
1019
- **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. {§14.8-no-fork-session}
1020
-
1021
- **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 §14.3 security line), and duplicates what FOLD already does at the right level. Every "which run / what's copied / where's the per-client view" answers itself once the world/log line is drawn.
1022
-
1023
- **Migration path.** Mostly stating what the schema already carries: `runs.parent_run_id` and the parentless `sessions` exist (§0.1); `session_constraints` is session-level (§14.3); §14.5 already makes a run's timeline self-contained, so a fork's log copy suffices. Additive: the savepoint/branch *operation* and `run.fork` over the wire. Two repatriations: §14.7's "read-only overlay scopes a run's writable surface" becomes a *session* policy bounding every run uniformly; and the §14.5 environment door sheds its per-run watermark — a run's only memory is its log, so drift is the broadcast (run-caused) and the build-time disk-vs-entry diff (ambient), both landing as log entries, never a per-run snapshot.
1071
+ **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.
1024
1072
 
1025
1073
  ---
1026
1074
 
1027
- ## §15 Packet shape
1075
+ ## §packet Packet shape
1028
1076
 
1029
- Canonical shape defined by `@plurnk/plurnk-grammar` (`schema/Packet.json`). Engine assembles in `Engine.#buildRequestPacket`; no plugin augmentation (§14.1). This section is plurnk-service's responsibilities under that contract.
1077
+ Canonical shape defined by `@plurnk/plurnk-grammar` (`schema/Packet.json`). Engine assembles in `Engine.#buildRequestPacket`; no plugin augmentation (§packet-assembly). This section is plurnk-service's responsibilities under that contract.
1030
1078
 
1031
1079
  ```ts
1032
1080
  type Packet = {
@@ -1035,14 +1083,14 @@ type Packet = {
1035
1083
  tokens: number;
1036
1084
  system_definition: string;
1037
1085
  persona: string;
1038
- index: PacketEntry[]; // visible entries (§4 / §5)
1039
- log: PacketLogRow[]; // chronological action-entries (§7)
1086
+ index: PacketEntry[]; // visible entries (§mimetype / §channels)
1087
+ log: PacketLogRow[]; // chronological action-entries (§stream)
1040
1088
  };
1041
1089
  user: {
1042
1090
  tokens: number;
1043
1091
  prompt: string;
1044
- telemetry: { budget: string; errors: object[] }; // §15.1
1045
- system_requirements: string; // §15.2
1092
+ telemetry: { budget: string; errors: object[] }; // §telemetry
1093
+ system_requirements: string; // §requirements
1046
1094
  };
1047
1095
  assistant: { tokens: number; content: string; ops: PlurnkStatement[]; reasoning: string | null };
1048
1096
  assistantRaw: unknown;
@@ -1051,9 +1099,9 @@ type Packet = {
1051
1099
 
1052
1100
  **Prompt as a first-class entry.** Each loop's prompt is written on loop start as a plurnk-origin `EDIT` against `plurnk://prompt/<loop_id>` (indexable, body channel, text/markdown). At render time the current loop's prompt body materializes into `packet.user.prompt`; the entry itself stays READ/FOLD-able like any other.
1053
1101
 
1054
- **The entry catalog.** `plurnk://manifest.json` is a real session entry the model READs to discover what's available — rewritten every turn as a live view of the full entry set. Built in the schemes layer (`_entry-manifest`) and materialized like any entry (the engine only orchestrates the per-turn write — the same pattern as git membership), so it's READable and queryable. Body is `application/json`: a flat, **complete, unranked** array — one item per entry across all schemes, every entry listed in no relevance order, each `{ path, channels: { <name>: { mimetype, tokens, lines } } }`. 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 write-time count (budget depth), `lines` the content extent from `Mimetypes.process().totalLines`. The engine counts neither. It does not list itself. {§15-manifest-catalog}
1102
+ **The entry catalog.** `plurnk://manifest.json` is a real session entry the model READs to discover what's available — rewritten every turn as a live view of the full entry set. Built in the schemes layer (`_entry-manifest`) and materialized like any entry (the engine only orchestrates the per-turn write — the same pattern as git membership), so it's READable and queryable. Body is `application/json`: a flat, **complete, unranked** array — one item per entry across all schemes, every entry listed in no relevance order, each `{ path, channels: { <name>: { mimetype, tokens, lines } } }`. 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 write-time count (budget depth), `lines` the content extent from `Mimetypes.process().totalLines`. The engine counts neither. It does not list itself. {§packet-manifest-catalog}
1055
1103
 
1056
- ### §15.1 user.telemetry — model-facing runtime telemetry
1104
+ ### §telemetry user.telemetry — model-facing runtime telemetry
1057
1105
 
1058
1106
  Slot for telemetry the model MUST react to immediately. Rendered at the bottom of the user section. Errors are transient — appear on the turn AFTER the failure, clear once seen. `packet.system.log[]` is the durable audit; `telemetry.errors[]` is the **alert**.
1059
1107
 
@@ -1064,9 +1112,9 @@ Slot for telemetry the model MUST react to immediately. Rendered at the bottom o
1064
1112
 
1065
1113
  **Plurnk-service rendering:**
1066
1114
 
1067
- - `budget` per §14.2: per-scheme breakdown table with `tokenCeiling`/`tokenUsage`/`tokensFree`.
1115
+ - `budget` per §tokenomics: per-scheme breakdown table with `tokenCeiling`/`tokenUsage`/`tokensFree`.
1068
1116
  - `errors[]` from previous turn's dispatch. Required: `kind` discriminator. Additional kind-specific fields are flat on the element — NO nested `detail`. Canonical-JSON serialization sorts keys for prefix-cache friendliness.
1069
- - Wire: one `* {canonical-JSON}` line per error under `# Plurnk System Errors`, push order. Buffer drains on read. {§15.1-drain-on-read}
1117
+ - Wire: one `* {canonical-JSON}` line per error under `# Plurnk System Errors`, push order. Buffer drains on read. {§telemetry-drain-on-read}
1070
1118
  - **No prose `message` field.** Errors carry structured facts. The `kind` is the alert; the named fields are the data. Guidance, advice, hints, and exhortation MUST NOT appear in telemetry. Letting the model infer what to do from facts (and the log) beats handing it instructions it will second-guess.
1071
1119
  - **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. 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.
1072
1120
 
@@ -1079,29 +1127,37 @@ Slot for telemetry the model MUST react to immediately. Rendered at the bottom o
1079
1127
  | `max_commands_exceeded` | Single emission exceeded `PLURNK_MAX_COMMANDS` cap; overflow ops dropped without dispatch | `source: "engine:rail"`, `kind`, `emitted`, `dropped` |
1080
1128
  | `budget_overflow` | Assembled packet exceeded the budget ceiling; entries moved out of the window to fit | `source: "engine:rail"`, `kind`, `hidden` (per-scheme `[{scheme, count}]` — entries removed from the window) |
1081
1129
 
1082
- Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeeping are all engine-internal — they drive abandonment silently per the gamification policy above. Action-bound failures (handler returned 4xx/5xx or threw) mirror as `action_failure` kind on the next packet. Full detail queryable via `log://`. {§15.1-no-error-scheme}
1130
+ Strike accounting, cycle detection, sudden-death thresholds, and no-ops bookkeeping are all engine-internal — they drive abandonment silently per the gamification policy above. Action-bound failures (handler returned 4xx/5xx or threw) mirror as `action_failure` kind on the next packet. Full detail queryable via `log://`. {§telemetry-no-error-scheme}
1083
1131
 
1084
1132
  **No `error://` scheme.** Actionless failures route to telemetry, not a queryable scheme namespace.
1085
1133
 
1086
- **Client surface: `telemetry/event` notification.** Every event the engine pushes to the loop's telemetry buffer also broadcasts live via the `telemetry/event` WS notification. Same envelope on both sides — `{ source, kind, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema. The model sees the event on the NEXT packet's `telemetry.errors[]` (drains on read); the client sees it the moment it lands. Client uses cases: render parse errors in a debug panel (the `snippet` field is content the model emitted), surface strike/sudden_death as "loop is degrading" toasts, log everything to a session timeline. Scoped to the loop's session. {§15.1-telemetry-event-notify}
1134
+ **Client surface: `telemetry/event` notification.** Every event the engine pushes to the loop's telemetry buffer also broadcasts live via the `telemetry/event` WS notification. Same envelope on both sides — `{ source, kind, message?, position?, …kind-specific }` per the grammar's `TelemetryEvent` schema. The model sees the event on the NEXT packet's `telemetry.errors[]` (drains on read); the client sees it the moment it lands. Client uses cases: render parse errors in a debug panel (the `snippet` field is content the model emitted), surface strike/sudden_death as "loop is degrading" toasts, log everything to a session timeline. Scoped to the loop's session. {§telemetry-telemetry-event-notify}
1087
1135
 
1088
- **Content-offset snippet rendering.** When telemetry carries `position: { type: "content-offset", line, column }`, plurnk-service extracts a ±N-line slice from the model's own prior `assistant.content` and renders it as an `N:\t`-prefixed heredoc under an `error://<line>` fence, immediately following the event meta line. Without the snippet, the model gets "invalid xpath at 1:0" with no way to trace what it wrote at 1:0 — and tends to regenerate the same broken emission. With it, recovery is direct (canonical case: the edit-todo demo where a READ body starting with `//` got xpath-dispatched). The snippet field is stripped from the meta JSON so it appears once, in the body block. {§15.1-content-offset-snippet}
1136
+ **Content-offset snippet rendering.** When telemetry carries `position: { type: "content-offset", line, column }`, plurnk-service extracts a ±N-line slice from the model's own prior `assistant.content` and renders it as an `N:\t`-prefixed heredoc under an `error://<line>` fence, immediately following the event meta line. Without the snippet, the model gets "invalid xpath at 1:0" with no way to trace what it wrote at 1:0 — and tends to regenerate the same broken emission. With it, recovery is direct (canonical case: the edit-todo demo where a READ body starting with `//` got xpath-dispatched). The snippet field is stripped from the meta JSON so it appears once, in the body block. {§telemetry-content-offset-snippet}
1089
1137
 
1090
- ### §15.2 user.system_requirements — static per-turn rules
1138
+ ### §requirements user.system_requirements — static per-turn rules
1091
1139
 
1092
- Rendered at the END of the user packet under `# Plurnk System Requirements` {§15.2-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. {§15.2-requirements-omitted-when-empty} Contains rules the grammar block doesn't cover (canonical example: "Conclude the loop with `<<SEND[200]:answer:SEND`").
1140
+ Rendered at the END of the user packet under `# Plurnk System 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`").
1093
1141
 
1094
1142
  **Sourcing:** caller supplies the string via `runLoop({ requirements })` / `runTurn({ requirements })`. Plurnk-service exposes `PATHS.defaultRequirements` (resolves `PLURNK_REQUIREMENTS` env → in-package `requirements.md`). No DB cascade — same string every turn.
1095
1143
 
1096
1144
  **Rationale:** the user's prompt is natural language ("Reply with just the number") and routinely conflicts with the grammar's operational contract. Without an explicit requirement block, the model obeys the prompt literally and never reaches for SEND. Requirements are the contract that wins those conflicts.
1097
1145
 
1146
+ ### §persona Persona — the per-entity cascade
1147
+
1148
+ The persona — the character the model wears, rendered into `packet.system` — resolves per turn at packet assembly by a cascade over three nullable columns plus a file default. **`loops.persona` > `runs.persona` > `sessions.persona` > the `PLURNK_PERSONA` file** (`engine_resolve_persona` is `COALESCE(loop, run, session)`, falling to `PATHS.defaultPersona` when all three are null); the most specific level set wins. {§persona-cascade-precedence}
1149
+
1150
+ **Null falls through; empty string overrides.** A null at a level defers to the next; an explicit `""` is a non-null value that wins the COALESCE and **suppresses** the cascade — the model gets no persona section. Setting `""` is how a client deliberately strips an inherited persona for one loop. {§persona-null-falls-through} {§persona-empty-suppresses}
1151
+
1152
+ **Set by RPC, evaluated at build.** `session.create` / `session.set_persona` set the session level, `session.attach` sets a *new* run's level, `loop.run({persona})` sets the loop level (highest precedence). The cascade resolves fresh each turn — not frozen at loop start — so a runtime `session.set_persona` lands on the next turn.
1153
+
1098
1154
  ---
1099
1155
 
1100
- ## §16 Matcher and `<L>` slicing
1156
+ ## §matcher Matcher and `<L>` slicing
1101
1157
 
1102
1158
  Body matchers and `<L>` both dispatch on entry mimetype. Body matcher: leading-char classification (`//` xpath, `/` regex, `$` jsonpath, otherwise glob). `<L>`: line-navigable → by line, structured → by item.
1103
1159
 
1104
- ### §16.1 Matcher dispatch (delegated to `Mimetypes.query`)
1160
+ ### §matcher-dispatch Matcher dispatch (delegated to `Mimetypes.query`)
1105
1161
 
1106
1162
  `matchAgainstContent` (exported from `@plurnk/plurnk-schemes`) is an adapter over `Mimetypes.query(input, expression)`. Framework parses leading prefix, resolves per-mimetype handler, returns `QueryMatch[]`. Adapter maps typed errors:
1107
1163
 
@@ -1113,11 +1169,11 @@ Body matchers and `<L>` both dispatch on entry mimetype. Body matcher: leading-c
1113
1169
  | Empty match array | 204 |
1114
1170
  | Match array | 200 |
1115
1171
 
1116
- 203 is HTTP-creative ("Non-Authoritative Information"). On parse failure, returns raw bytes as text primitive with `reason` so the model can fall back to regex/visual parsing or fix source. {§16.1-203-soft-fallback}
1172
+ 203 is HTTP-creative ("Non-Authoritative Information"). On parse failure, returns raw bytes as text primitive with `reason` so the model can fall back to regex/visual parsing or fix source. {§matcher-dispatch-203-soft-fallback}
1117
1173
 
1118
1174
  Glob anchoring (`TODO*` starts-with, `*TODO*` contains, `*.log` ends-with, `[Tt]odo*` char class) lives in framework's `BaseHandler`.
1119
1175
 
1120
- ### §16.2 Matcher result shape (uniform across dialects)
1176
+ ### §matcher-result Matcher result shape (uniform across dialects)
1121
1177
 
1122
1178
  Body: one match per line as `<line>:\t<value>` — the same `N:\t` form READ emits, so `<L>` can page the result set. Empty → 204. Mimetype = `text/markdown` regardless of source dialect.
1123
1179
 
@@ -1138,7 +1194,7 @@ Body: one match per line as `<line>:\t<value>` — the same `N:\t` form READ emi
1138
1194
  | jsonpath `$.path` | JSON values (parsed value for JSON-shaped mimetypes; bare-leaves outline for markdown/HTML/source) | get the host field / jump to Installation |
1139
1195
  | xpath `//sel` | XML nodes/text/attrs (text/html only) | get the h1 contents |
1140
1196
 
1141
- ### §16.3 `<L>` semantics by source mimetype
1197
+ ### §slice-semantics `<L>` semantics by source mimetype
1142
1198
 
1143
1199
  **General**: sentinels `<0>` (before pos 1) and `<-1>` (after last) are EDIT insertion points; READ/COPY select empty. Other negatives in a single-position marker → 416. In a range, `M = -1` normalizes to "last" so `<1,-1>` is the whole content.
1144
1200
 
@@ -1160,16 +1216,16 @@ Body: one match per line as `<line>:\t<value>` — the same `N:\t` form READ emi
1160
1216
  - `<0>` / `<-1>` → `[]` for READ
1161
1217
  - Out-of-range → 416; malformed JSON → 400
1162
1218
 
1163
- **Killer composition.** `<<READ(log://N/M/K)<P>::READ` picks the P-th match from a prior matcher result — matcher rx is `application/json`, structural `<L>` selects the P-th element. {§16.3-compose-pattern}
1219
+ **Killer composition.** `<<READ(log://N/M/K)<P>::READ` picks the P-th match from a prior matcher result — matcher rx is `application/json`, structural `<L>` selects the P-th element. {§slice-semantics-compose-pattern}
1164
1220
 
1165
- ### §16.4 Structural EDIT on JSON
1221
+ ### §json-edit Structural EDIT on JSON
1166
1222
 
1167
- When effective mimetype is `application/json`, EDIT dispatches through `applyJsonItemEdit`. {§16.4-structural-json-edit} Body shape rule (parse-then-discriminate):
1223
+ When effective mimetype is `application/json`, EDIT dispatches through `applyJsonItemEdit`. {§json-edit-structural-json-edit} Body shape rule (parse-then-discriminate):
1168
1224
 
1169
1225
  - Body parses as JSON array → items to splice
1170
1226
  - Body parses as non-array JSON → single item to splice
1171
1227
  - Empty body → delete the selection
1172
- - Body fails JSON parse → 400 (path-extension declares intent; honor strictly) {§16.4-json-parse-fail-400}
1228
+ - Body fails JSON parse → 400 (path-extension declares intent; honor strictly) {§json-edit-json-parse-fail-400}
1173
1229
 
1174
1230
  **Array source marker × body:**
1175
1231
 
@@ -1191,37 +1247,37 @@ When effective mimetype is `application/json`, EDIT dispatches through `applyJso
1191
1247
 
1192
1248
  **Scalar source**: `<1>` replaces only. Grow markers (`<-1>`, `<0>`) and multi-item bodies → 400 (no implicit promotion scalar→array).
1193
1249
 
1194
- ### §16.5 Path-extension declares mimetype
1250
+ ### §ext-mimetype Path-extension declares mimetype
1195
1251
 
1196
- `resolveEntryMimetype` (exported from `@plurnk/plurnk-schemes`): pathname extension → `Mimetypes.detect({ ext })` (with `text/plain` normalized to `text/markdown` per the text-primitive rule §16.7); falls back to scheme manifest channel default when no extension.
1252
+ `resolveEntryMimetype` (exported from `@plurnk/plurnk-schemes`): pathname extension → `Mimetypes.detect({ ext })` (with `text/plain` normalized to `text/markdown` per the text-primitive rule §markdown-primitive); falls back to scheme manifest channel default when no extension.
1197
1253
 
1198
1254
  - `known://users.json` → `application/json` (extension wins)
1199
1255
  - `known://notes.md` → `text/markdown` (extension; matches default)
1200
1256
  - `known://config.yaml` → `application/yaml`
1201
1257
  - `known://users` (no suffix) → `text/markdown` (Known manifest default)
1202
1258
 
1203
- Same rule applies across Known, Unknown, Skill, Plurnk, File. Effective mimetype is stored in `entry_channels.mimetype` on write and drives `<L>` and matcher dispatch on read. {§16.5-extension-mimetype}
1259
+ Same rule applies across Known, Unknown, Skill, Plurnk, File. Effective mimetype is stored in `entry_channels.mimetype` on write and drives `<L>` and matcher dispatch on read. {§ext-mimetype-extension-mimetype}
1204
1260
 
1205
- ### §16.6 Render rule (mimetype-driven)
1261
+ ### §render-rule Render rule (mimetype-driven)
1206
1262
 
1207
1263
  `packet-wire` log render branches on `isLineNavigableMimetype`:
1208
1264
 
1209
- - **Line-navigable** (text/markdown, text/plain, csv, source code, yaml, toml) → `N:\t` line-number prefix per line {§16.6-line-navigable-prefix}
1210
- - **Tree-navigable** (application/json, application/xml, text/html, +json/+xml suffixes) → verbatim body (no `N:\t` — outer line numbers would collide with structural navigation like jsonpath/xpath) {§16.6-tree-navigable-verbatim}
1265
+ - **Line-navigable** (text/markdown, text/plain, csv, source code, yaml, toml) → `N:\t` line-number prefix per line {§render-rule-line-navigable-prefix}
1266
+ - **Tree-navigable** (application/json, application/xml, text/html, +json/+xml suffixes) → verbatim body (no `N:\t` — outer line numbers would collide with structural navigation like jsonpath/xpath) {§render-rule-tree-navigable-verbatim}
1211
1267
 
1212
1268
  The `N:\t` prefix is presentation/reference per plurnk.md ("not part of the source"); stripped before any matcher operation on the log entry.
1213
1269
 
1214
- ### §16.7 Mimetype primitive: text/markdown
1270
+ ### §markdown-primitive Mimetype primitive: text/markdown
1215
1271
 
1216
1272
  Auto-derived text mimetypes anywhere in plurnk-service normalize to `text/markdown`:
1217
1273
 
1218
- - `<L>` slice on line-navigable source → `text/markdown` {§16.7-text-markdown-normalize}
1274
+ - `<L>` slice on line-navigable source → `text/markdown` {§markdown-primitive-text-markdown-normalize}
1219
1275
  - File scheme extension fallback → `text/markdown`
1220
1276
  - `Mimetypes.detect()` returning `text/plain` → normalized via `normalizeAutoTextMimetype`
1221
1277
 
1222
1278
  `text/plain` survives only where a scheme explicitly declares it (exec stdout/stderr — subprocess byte-streams aren't markdown). The model never auto-encounters `text/plain` from defaults.
1223
1279
 
1224
- ### §16.9 Op-level invariants and resolved ambiguities
1280
+ ### §op-invariants Op-level invariants and resolved ambiguities
1225
1281
 
1226
1282
  Carried from the contract walk; durable.
1227
1283
 
@@ -1229,12 +1285,12 @@ Carried from the contract walk; durable.
1229
1285
  - **Binary entries** → 415 across the board for READ/EDIT/OPEN/FOLD.
1230
1286
  - **EDIT `<L>` on non-existent entry** → body becomes content; `<L>` is positional-only on existing content.
1231
1287
  - **COPY `<L>`** → source range, symmetric with READ `<L>`.
1232
- - **READ rx** prefixes each line with `N:\t` per §16.6. `sliceLinesRaw` (used by COPY) returns the lines without prefix.
1288
+ - **READ rx** prefixes each line with `N:\t` per §render-rule. `sliceLinesRaw` (used by COPY) returns the lines without prefix.
1233
1289
  - **FIND body matcher** applies to entry content (all dialects), per-candidate via `Matcher.matchAgainstContent` → `Mimetypes.query` (status 200 = content hit → entry selected). Scope + tags select candidates in SQL; the path-glob is the (target).
1234
- - **OPEN/FOLD** operate on the **log** (`log://`), not entries (§6.3) — FOLD collapses a log row to its path, OPEN restores its body. Aimed at an entry scheme they return 501.
1235
- - **SEND[410]** deletes as a side-effect (not the model idiom; §6.5): with `#fragment`, that channel only; without, the whole entry. **SEND[499]** is owned by the streaming scheme that holds the subscription.
1290
+ - **OPEN/FOLD** operate on the **log** (`log://`), not entries (§open-fold) — FOLD collapses a log row to its path, OPEN restores its body. Aimed at an entry scheme they return 501.
1291
+ - **SEND[410]** deletes as a side-effect (not the model idiom; §move): with `#fragment`, that channel only; without, the whole entry. **SEND[499]** is owned by the streaming scheme that holds the subscription.
1236
1292
  - **File scheme** reads disk content with mimetype detected via `Mimetypes.detect({ path })` (plumbed through `PlurnkSchemeContext.mimetypes`). Binary mimetypes → 415 on READ and EDIT.
1237
1293
 
1238
- ### §16.10 Directed-SEND status code policy
1294
+ ### §send-status-policy Directed-SEND status code policy
1239
1295
 
1240
1296
  Status codes outside 410/499 on directed SEND return 501 from entry schemes. plurnk.md doesn't prescribe semantics for arbitrary HTTP status codes on directed sends; each scheme decides. 501 is the default; new interpretations land as concrete use cases arise.