textus 0.51.0 → 0.53.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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
data/SPEC.md CHANGED
@@ -22,18 +22,17 @@
22
22
  - [5.1 Role resolution](#51-role-resolution)
23
23
  - [5.1.1 Capabilities](#511-capabilities)
24
24
  - [5.2 Source layer (produced entries)](#52-source-layer-produced-entries)
25
- - [5.2.1 Projection source (`from: project`)](#521-projection-source-from-project)
26
- - [5.2.2 External source (`from: command`)](#522-external-source-from-command)
25
+ - [5.2.1 Derived source (`from: derive`)](#521-derived-source-from-derive)
26
+ - [5.2.2 External source (`from: external`)](#522-external-source-from-external)
27
27
  - [5.3 Publish layer](#53-publish-layer-publish)
28
- - [5.4 Intake source (`from: handler`)](#54-intake-source-from-handler)
28
+ - [5.4 Intake source (`from: fetch`)](#54-intake-source-from-fetch)
29
29
  - [5.5 Pending / accept workflow](#55-pending--accept-workflow)
30
30
  - [5.6 Audit log](#56-audit-log)
31
31
  - [5.7 Security bounds](#57-security-bounds)
32
32
  - [5.8 Schema evolution](#58-schema-evolution)
33
- - [5.9 Row transforms](#59-row-transforms)
34
- - [5.10 Hooks](#510-hooks)
35
- - [5.11 Rules](#511-rules)
36
- - [5.12 Storage formats](#512-storage-formats)
33
+ - [5.9 Hooks](#59-hooks)
34
+ - [5.10 Rules](#510-rules)
35
+ - [5.11 Storage formats](#511-storage-formats)
37
36
  - [6. Schemas](#6-schemas)
38
37
  - [7. Entry file format](#7-entry-file-format)
39
38
  - [8. Envelope (the wire format)](#8-envelope-the-wire-format)
@@ -48,10 +47,6 @@
48
47
  - [13.1 Layered architecture (internal)](#131-layered-architecture-internal)
49
48
  - [14. Open questions (v3.x scope)](#14-open-questions-v3x-scope)
50
49
  - [15. Implementation checklist](#15-implementation-checklist)
51
- - [16. Migrating from textus/2](#16-migrating-from-textus2)
52
- - [16.1 Breaking changes in 0.31.0 (capability-based roles)](#161-breaking-changes-in-0310-capability-based-roles)
53
- - [16.2 Breaking changes in 0.33.0 (workspace/keep + Setup-1 scaffold)](#162-breaking-changes-in-0330-workspacekeep--setup-1-scaffold)
54
- - [16.3 Breaking changes in 0.35.0 (proposal target-canon + `author_held`)](#163-breaking-changes-in-0350-proposal-target-canon--author_held)
55
50
 
56
51
  ---
57
52
 
@@ -76,7 +71,7 @@ implementation is the bug.
76
71
 
77
72
  A storage convention and JSON wire protocol for humans, agents, and automation to read and write structured project memory **deterministically**. It provides addressable dotted keys, schema validation, capability-based write gates, declarative data sources, and a list of publish targets that copy or render that data.
78
73
 
79
- The storage lives in a `.textus/` directory at the project root. Each entry is a Markdown file with YAML frontmatter. A manifest binds dotted keys to subtrees, declares the capabilities each role holds, and declares each zone's kind — write authority for a zone is derived from the role's capabilities and the zone's kind. Schemas (also YAML) define what frontmatter shape each entry must have. Produced entries acquire their data via a declared `source:` (a pure projection over other entries, an external fetch, or an out-of-band command); that data is then optionally published to repo-relative paths — copied verbatim, or rendered through a per-target Mustache template. The CLI surface (`textus get/put/list/where/schema/reconcile/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
74
+ The storage lives in a `.textus/` directory at the project root. Each entry is a Markdown file with YAML frontmatter. A manifest binds dotted keys to subtrees, declares the capabilities each role holds, and declares each zone's kind — write authority for a zone is derived from the role's capabilities and the zone's kind. Schemas (also YAML) define what frontmatter shape each entry must have. Produced entries acquire their data via a declared `source:` (a pure projection over other entries, an external fetch, or an out-of-band command); that data is then optionally published to repo-relative paths — copied verbatim, or rendered through a per-target Mustache template. The CLI surface (`textus get/put/list/where/schema/drain/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
80
75
 
81
76
  You **shape your own memory structure** inside `.textus/`. The protocol manages how it's read, written, addressed, validated, gated, computed, and published. The contents are entirely yours.
82
77
 
@@ -84,10 +79,10 @@ You **shape your own memory structure** inside `.textus/`. The protocol manages
84
79
 
85
80
  textus/3 names its concepts along six axes. Reviewers who internalize these can map any part of the spec to the right category:
86
81
 
87
- - **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `reconcile`).
82
+ - **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `converge`).
88
83
  - **Place** — where data lives: zones such as `knowledge`, `notebook`, `feeds`, `proposals`, `artifacts`.
89
84
  - **Thing** — what is stored: entries, fields, keys.
90
- - **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `fetch`, `reconcile`, …).
85
+ - **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `drain`, `serve`, …).
91
86
  - **Event** — what gets fired after an operation: hook event names, split into RPC events (`:resolve_handler`, `:transform_rows`, `:validate`) and pub-sub events (`:entry_written`, `:entry_produced`, …).
92
87
  - **Rule** — constraints declared in the top-level `rules:` array of the manifest.
93
88
 
@@ -97,8 +92,8 @@ textus is organized as five composable layers. Each layer has a single responsib
97
92
 
98
93
  | Layer | Name | Responsibility |
99
94
  |---|---|---|
100
- | L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
101
- | L2 | **Sources** | Declared external inputs (the `artifacts` machine zone in the default scaffold, under the `artifacts.feeds.*` keys; any `machine` zone, writable by a role with `reconcile`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
95
+ | L1 | **Store** | Plain-file backend: `.textus/data/<lane>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
96
+ | L2 | **Sources** | Declared external inputs (the `artifacts` machine zone in the default scaffold, under the `artifacts.feeds.*` keys; any `machine` zone, writable by a role with `converge`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
102
97
  | L3 | **Source** | An entry's `source:` *acquires* **data** — a pure in-process projection from store entries (select/pluck/sort/transform), an external fetch via a handler, or an out-of-band command. Acquire-only: rendering is not a source concern. No shell execution. |
103
98
  | L4 | **Publish** | Emits a produced entry's data to repo-relative paths, declared via a **list** of `publish:` targets. A target with no `template:` copies the data verbatim (json/yaml re-serialized without `_meta`; other formats byte-copied); a target with a `template:` renders the data through it. A `{ tree: }` target mirrors a subtree (ADR 0047). Published artifacts are clean content — textus's `_meta` provenance stays in the store. A sentinel under `.textus/.run/sentinels/<target-rel-path>.textus-managed.json` (git-ignored runtime state) records the source, sha256, and `mode: "copy"`. |
104
99
  | L5 | **Consumers** | Anything that reads the published files or calls the CLI — editors, LLM tools, MCP servers, CI jobs, dashboards. textus is agnostic about who consumes; the envelope is the contract. |
@@ -130,24 +125,25 @@ The root is `.textus/` at the project working directory. A typical tree:
130
125
 
131
126
  ```
132
127
  .textus/
133
- manifest.yaml # internal: key → subtree mapping + role/zone declarations
134
- audit.log # internal, append-only NDJSON log of every successful write
128
+ manifest.yaml # internal: key → subtree mapping + role/lane declarations
135
129
  schemas/ # internal: YAML schema files
136
130
  templates/ # internal: Mustache templates referenced by derived entries
137
- hooks/ # internal: one Ruby file per hook
138
- .run/sentinels/ # runtime (git-ignored): byte-copied publish bookkeeping, regenerated on build (see §5.3)
139
- zones/ # ALL user content lives here
140
- knowledge/ # zone: knowledge (kind: canon — author-holders write; knowledge.identity.* is the identity convention)
141
- notebook/ # zone: notebook (kind: workspace — keep-holders write; agent's own durable lane)
142
- proposals/ # zone: proposals (kind: queuepropose-holders write)
143
- artifacts/ # zone: artifacts (kind: machinereconcile-holders write; external inputs artifacts.feeds.* + computed outputs artifacts.derived.*)
131
+ steps/ # internal: user-injectable step handlers (fetch, derive, external)
132
+ .run/ # runtime (git-ignored): audit log, sentinels, locks, queue, pulse cursors
133
+ audit.log # append-only NDJSON log of every successful write
134
+ sentinels/ # byte-copied publish bookkeeping (see §5.3)
135
+ data/ # ALL user content lives here
136
+ knowledge/ # lane: knowledge (kind: canonauthor-holders write)
137
+ notebook/ # lane: notebook (kind: workspacekeep-holders write; agent's own durable lane)
138
+ proposals/ # lane: proposals (kind: queue — propose-holders write)
139
+ artifacts/ # lane: artifacts (kind: machine — converge-holders write)
144
140
  ```
145
141
 
146
- Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `hooks/`) live directly under `.textus/`; disposable runtime state (the audit log, publish `sentinels/`, fetch/build locks, pulse cursors) lives under `.textus/.run/` (git-ignored, ADR 0038/0070). **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
142
+ Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `steps/`) live directly under `.textus/`; disposable runtime state (audit log, publish `sentinels/`, fetch/build locks, pulse cursors, job queue) lives under `.textus/.run/` (git-ignored, ADR 0038/0070). **All user content lives under `.textus/data/`.** Manifest `path:` fields are relative to `.textus/` — they include the `data/` prefix explicitly (e.g. `path: data/knowledge/foo.md`).
147
143
 
148
- Zone directories under `zones/` are conventional; their write semantics are derived from the zone's declared `kind:` (and the capabilities roles hold), not the directory name.
144
+ Lane directories under `data/` are conventional; their write semantics are derived from the lane's declared `kind:` (and the capabilities roles hold), not the directory name.
149
145
 
150
- `.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `key_delete`, `accept`, and `reconcile`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
146
+ `.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `key_delete`, `key_mv`, and `accept`. Convergence (`drain`/`serve`) writes through these same verbs — a produced entry logs as `put`, a swept entry as `key_delete` — so there is no distinct `drain` audit verb. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
151
147
 
152
148
  ### 3.1 Store location precedence
153
149
 
@@ -170,9 +166,9 @@ version: textus/3
170
166
  roles:
171
167
  - { name: human, can: [author, propose] }
172
168
  - { name: agent, can: [propose] }
173
- - { name: automation, can: [reconcile] }
169
+ - { name: automation, can: [converge] }
174
170
 
175
- zones:
171
+ lanes:
176
172
  - name: knowledge
177
173
  kind: canon
178
174
  - name: notebook
@@ -186,22 +182,22 @@ zones:
186
182
 
187
183
  entries:
188
184
  - key: knowledge.identity.self
189
- path: knowledge/identity/self.md
190
- zone: knowledge
185
+ path: data/knowledge/identity/self.md
186
+ lane: knowledge
191
187
  schema: identity
192
188
 
193
189
  - key: knowledge.network.org
194
- path: knowledge/network/org
195
- zone: knowledge
190
+ path: data/knowledge/network/org
191
+ lane: knowledge
196
192
  schema: person
197
193
  owner: human:network
198
194
  nested: true
199
195
 
200
196
  - key: artifacts.catalogs.people
201
- path: artifacts/catalogs/people.md
202
- zone: artifacts
197
+ path: data/artifacts/catalogs/people.md
198
+ lane: artifacts
203
199
  schema: null
204
- owner: automation:reconcile
200
+ owner: automation:converge
205
201
 
206
202
  rules:
207
203
  - match: artifacts.feeds.**
@@ -229,12 +225,12 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
229
225
 
230
226
  **The `publish:` list (ADR 0052, ADR 0094).** Publishing is configured by a `publish:` **list** of targets; each element is exactly one of a to-target `{ to:, template?:, inject_boot?: }` (file emit, §5.3) or a tree-target `{ tree: }` (subtree mirror, below). The legacy *map* forms (`publish: { to: [...] }`, `publish: { tree: ... }`) and the older top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message — `publish:` is a list, and a mirror is a `{ tree: }` element of it.
231
227
 
232
- **Subtree mirror (a `{ tree: }` target).** A nested manifest entry MAY include a `{ tree: "dir" }` target to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). It is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every reconcile the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). When a `{ tree: }` target directory overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
228
+ **Subtree mirror (a `{ tree: }` target).** A nested manifest entry MAY include a `{ tree: "dir" }` target to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). It is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every drain the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). When a `{ tree: }` target directory overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
233
229
 
234
230
  ```yaml
235
- - key: working.skills
236
- path: working/skills
237
- zone: working
231
+ - key: knowledge.skills
232
+ path: data/knowledge/skills
233
+ lane: knowledge
238
234
  schema: skill
239
235
  nested: true
240
236
  publish:
@@ -242,11 +238,9 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
242
238
  ignore: ["*.tmp", ".DS_Store"]
243
239
  ```
244
240
 
245
- **`inject_boot:` (a publish-target flag).** A to-target with a `template:` MAY declare `inject_boot: true`. When `textus reconcile` publishes that target, it merges the `textus boot` envelope (§9) into the render data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside the entry's data. `inject_boot:` is per-target and only meaningful alongside a `template:`; on a templateless or tree target it is rejected at load — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
241
+ **Lookup rule:** to resolve a key, find the entry with the longest `key:` prefix that matches. If that entry has `nested: true`, the remaining segments map to subdirectories under its `path`. Otherwise the key must equal an entry exactly. The resolved filesystem path is `<.textus root>/<entry.path>[/<remaining>...].md` manifest `path:` values include the `data/` prefix (e.g. `data/knowledge/network/org`).
246
242
 
247
- **Lookup rule:** to resolve a key, find the entry with the longest `key:` prefix that matches. If that entry has `nested: true`, the remaining segments map to subdirectories under its `path`. Otherwise the key must equal an entry exactly. The resolved filesystem path is `<.textus root>/zones/<entry.path>[/<remaining>...].md` — implementations MUST prepend `zones/` to the manifest `path:` when constructing the filesystem location.
248
-
249
- ## 5. Zones and capability-based write gates
243
+ ## 5. Lanes and capability-based write gates
250
244
 
251
245
  Write authority is **derived**, never declared per-zone. Each zone declares a `kind:`; each zone-kind requires one capability to write to it. A role may write a zone iff its capability set (`role.can`) contains the verb that zone-kind requires. textus gates **writes, not reads**: reads are unrestricted at the protocol layer (the `.textus/` files are on disk). Per-role read-scoping, if needed, is an agent-surface projection, not a manifest field.
252
246
 
@@ -256,21 +250,21 @@ The kind→verb mapping is closed:
256
250
  |---|---|---|
257
251
  | `canon` | `author` | Authored truth — only the trust anchor writes directly. |
258
252
  | `workspace` | `keep` | Agent's own durable lane — bytes never auto-promote; climb to `canon` only via propose→accept. |
259
- | `machine` | `reconcile` | Machine-maintained: external bytes pending validation + outputs computed from other zones. |
253
+ | `machine` | `converge` | Machine-maintained: external bytes pending validation + outputs computed from other zones. |
260
254
  | `queue` | `propose` | Proposals awaiting promotion. |
261
255
 
262
- This is a **bijection** (zone-kind ⟺ capability) again (ADR 0091, which folded the former `quarantine` + `derived` kinds — split apart in ADR 0090 — back into one `machine` kind): the single `machine` lane requires `reconcile`, because machine-maintained bytes (external inputs and computed outputs alike) are kept current by the same `reconcile` sweep.
256
+ This is a **bijection** (zone-kind ⟺ capability) again (ADR 0091, which folded the former `quarantine` + `derived` kinds — split apart in ADR 0090 — back into one `machine` kind): the single `machine` lane requires `converge`, because machine-maintained bytes (external inputs and computed outputs alike) are kept current by the same convergence sweep (`drain`/`serve`).
263
257
 
264
258
  `owner:` on a zone is OPTIONAL, INFORMATIONAL metadata (not enforced in 0.33.0 — owner-scoped enforcement is deferred). `desc:` on a zone is optional; the value surfaces as the `purpose` field in `textus boot` zone rows.
265
259
 
266
- Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[reconcile]`):
260
+ Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[converge]`):
267
261
 
268
262
  | Zone | `kind` | Required capability | Writable by (default) | Use case |
269
263
  |---|---|---|---|---|
270
264
  | `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network. `knowledge.identity.*` is the identity key convention. |
271
265
  | `notebook` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
272
266
  | `proposals` | `queue` | `propose` | `agent`, `human` | Proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `knowledge`. |
273
- | `artifacts` | `machine` | `reconcile` | `automation` | Machine-maintained, never by humans or agents directly: declared external inputs (calendar, feeds, scraped pages) under `artifacts.feeds.*` pulled in by the `reconcile` sweep, and computed outputs (catalogs, indexes, published context) under `artifacts.derived.*` materialized via `textus reconcile`. |
267
+ | `artifacts` | `machine` | `converge` | `automation` | Machine-maintained, never by humans or agents directly: declared external inputs (calendar, feeds, scraped pages) under `artifacts.feeds.*` pulled in by the convergence sweep, and computed outputs (catalogs, indexes, published context) under `artifacts.derived.*` materialized via `textus drain`. |
274
268
 
275
269
  A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role does not hold the capability the target zone-kind requires, the write returns `write_forbidden` with the message `writing '<key>' (zone '<zone>') needs capability '<verb>'` and a hint naming the roles that hold it (`held by: <roles>`, or `held by: no declared role` when none do).
276
270
 
@@ -281,7 +275,7 @@ durable lane), `machine` (machine-maintained: external bytes pending validation
281
275
  manifest MUST declare at most one `queue` zone and at most one `machine` zone.
282
276
  Because authority is derived, a manifest is rejected at load if it declares a
283
277
  zone whose required verb is held by **no** declared role (`machine` ⇒ a role with
284
- `reconcile`, `queue` ⇒ `propose`, `workspace` ⇒ `keep`, `canon` ⇒ `author`).
278
+ `converge`, `queue` ⇒ `propose`, `workspace` ⇒ `keep`, `canon` ⇒ `author`).
285
279
  Coordination is keyed off the declared kind: a zone is machine-maintained only if
286
280
  it declares `kind: machine`, and proposals route to the declared `queue` zone —
287
281
  there is no name-based fallback. A manifest with a kind-less zone is rejected at
@@ -302,7 +296,7 @@ The effective role for any CLI invocation is resolved in this order; the first m
302
296
  |---|---|---|
303
297
  | `human` | `[author, propose]` | Interactive user at a terminal; the single trust anchor. |
304
298
  | `agent` | `[propose]` | Long-running AI or LLM process; stages proposals. |
305
- | `automation` | `[reconcile]` | Scheduled or one-shot scripts: keep the `machine` lane current — pull external sources in and materialize computed outputs. |
299
+ | `automation` | `[converge]` | Scheduled or one-shot scripts: keep the `machine` lane current — pull external sources in and materialize computed outputs. |
306
300
 
307
301
  Roles are declared in the manifest's `roles:` block (§5.1.1); the names above are the default mapping when `roles:` is omitted. Unknown role values are rejected with `invalid_role`.
308
302
 
@@ -318,11 +312,11 @@ it holds via `can:`:
318
312
  roles:
319
313
  - { name: owner, can: [author, propose] }
320
314
  - { name: proposer, can: [propose] }
321
- - { name: machine, can: [reconcile] }
315
+ - { name: machine, can: [converge] }
322
316
  - { name: keeper, can: [keep] }
323
317
  ```
324
318
 
325
- Capability allow-list: `propose`, `author`, `keep`, `reconcile`. The mapping from
319
+ Capability allow-list: `propose`, `author`, `keep`, `converge`. The mapping from
326
320
  zone-kind to its required capability is a **bijection** (ADR 0091, which folded
327
321
  the former `quarantine` + `derived` kinds back into one `machine` kind — undoing
328
322
  the two-kind split of ADR 0090): each capability authorizes exactly one
@@ -333,11 +327,11 @@ zone-kind:
333
327
  | `author` | `canon` |
334
328
  | `keep` | `workspace` |
335
329
  | `propose` | `queue` |
336
- | `reconcile` | `machine` |
330
+ | `converge` | `machine` |
337
331
 
338
332
  A manifest naming a folded capability — `ingest` or `build`, or the pre-0088
339
333
  spelling `fetch` — in a `can:` list is rejected at load with a hint pointing to
340
- `reconcile` (ADR 0090, 0091).
334
+ `converge` (ADR 0090, 0091, 0111).
341
335
 
342
336
  `author` is the single **trust anchor**: **at most one role may hold `author`**
343
337
  (a manifest declaring two or more is rejected at load). The `accept` and
@@ -352,7 +346,7 @@ When the `roles:` block is omitted, the default mapping applies:
352
346
  |---|---|
353
347
  | `human` | `[author, propose]` |
354
348
  | `agent` | `[propose, keep]` |
355
- | `automation` | `[reconcile]` |
349
+ | `automation` | `[converge]` |
356
350
 
357
351
  Wire protocol `textus/3` is unchanged — capabilities are a manifest/semantics
358
352
  concept and never appear on the wire.
@@ -366,24 +360,24 @@ when the acting role holds `author`). See §5.11 for composing extra predicates
366
360
 
367
361
  ### 5.2 Source layer (produced entries)
368
362
 
369
- Produced entries live in a `machine` zone (writable by a role holding `reconcile`; `automation` by default) — `artifacts` in the default scaffold. They are not authored by hand; their **data** is acquired from a declared `source:` block with a `from:` discriminator (`project | handler | command`). A `source:` is **acquire-only**: it produces the data the store holds; it does **not** render. Rendering is a publish concern (§5.3). Every produced entry is `kind: produced` (ADR 0095); the **produce-method** is read from `source.from` — `from: project | command` is *derived* (internal projection / out-of-band command), `from: handler` is *intake* (external fetch, §5.4). `kind:` no longer restates the produce-method (the former `kind: derived` / `kind: intake` are rejected at load with a fold hint).
363
+ Produced entries live in a `machine` lane (writable by a role holding `converge`; `automation` by default) — `artifacts` in the default scaffold. They are not authored by hand; their **data** is acquired from a declared `source:` block with a `from:` discriminator (`derive | fetch | external`). A `source:` is **acquire-only**: it produces the data the store holds; it does **not** render. Rendering is a publish concern (§5.3). Every produced entry is `kind: produced` (ADR 0095); the **produce-method** is read from `source.from` — `from: derive | external` is *derived* (internal projection / out-of-band command), `from: fetch` is *intake* (external fetch, §5.4). `kind:` no longer restates the produce-method (the former `kind: derived` / `kind: intake` are rejected at load with a fold hint).
370
364
 
371
- #### 5.2.1 Projection source (`from: project`)
365
+ #### 5.2.1 Derived source (`from: derive`)
372
366
 
373
- A derived entry produced by a pure in-process projection declares `source: { from: project, ... }`. The projection fields are **flat** under `source:` (there is no nested `project:` block). The stored form is **data** — serialized via the `format:` strategy (e.g. `json`, `yaml`, `markdown-table`); no template is consulted at acquire time.
367
+ A derived entry produced by a pure in-process projection declares `source: { from: derive, ... }`. The projection fields are **flat** under `source:` (there is no nested `derive:` block). The stored form is **data** — serialized via the `format:` strategy (e.g. `json`, `yaml`, `markdown-table`); no template is consulted at acquire time.
374
368
 
375
369
  ```yaml
376
370
  - key: artifacts.derived.people
377
- kind: produced # produce-method (derived) read from source.from: project
378
- zone: artifacts
371
+ kind: produced # produce-method (derived) read from source.from: derive
372
+ lane: artifacts
373
+ path: data/artifacts/derived/people.json
379
374
  source:
380
- from: project
375
+ from: derive
381
376
  select: knowledge.network.org # prefix OR [list of prefixes]
382
377
  pluck: [name, relationship, org]
383
378
  sort_by: name # optional
384
379
  limit: 1000 # default 1000, max 1000
385
380
  format: json # one of: list, hash, yaml-list-in-md, json, markdown-table
386
- transform: rank_by_recency # optional — names a :transform_rows hook
387
381
  on_write: async # sync | async (default async)
388
382
  ```
389
383
 
@@ -391,24 +385,22 @@ A derived entry produced by a pure in-process projection declares `source: { fro
391
385
 
392
386
  `format` controls how the acquired data is serialized for storage. Permitted values: `list`, `hash`, `yaml-list-in-md`, `json`, `markdown-table`.
393
387
 
394
- `transform:` (optional) names a registered `:transform_rows` hook (see §5.10). The hook receives the projected rows array and may reorder, filter, or augment before serialization — it shapes the **data**, not its presentation.
395
-
396
388
  `on_write:` (`sync` | `async`, default `async`) controls the write-trigger strategy: `sync` rebuilds the entry's data inline before the triggering write returns; `async` defers it to a background pass that completes before process exit.
397
389
 
398
- > **No source-level `template:` / `inject_boot:` / `provenance:`.** Those are retired from `source:` (and from the entry top level). Rendering and boot injection move to a publish target (§5.3); provenance is carried in the data's `_meta` (§5.12), never a flag. A manifest carrying `source: { from: template }`, an entry-level/`source` `template:`/`inject_boot:`/`provenance:`, or a nested `project:` block is **rejected at load** with a fold hint (ADR 0094).
390
+ > **No source-level `template:` / `provenance:`.** Those are retired from `source:` (and from the entry top level). Rendering moves to a publish target (§5.3); provenance is carried in the data's `_meta` (§5.11). A manifest carrying `source: { from: template }` or an entry-level `template:`/`provenance:` is **rejected at load** with a fold hint (ADR 0094).
399
391
 
400
- #### 5.2.2 External source (`from: command`)
392
+ #### 5.2.2 External source (`from: external`)
401
393
 
402
- A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `source: { from: command, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the derived file's `_meta.generated.at` and report staleness. (Generator/build drift is dependency-based, not age-based; ADR 0085 keeps it out of the internal `freshness` scan — it is a `doctor` health check.)
394
+ A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `source: { from: external, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the derived file's `_meta.generated.at` and report staleness. (Generator/build drift is dependency-based, not age-based; ADR 0085 keeps it out of the internal `freshness` scan — it is a `doctor` health check.)
403
395
 
404
396
  ```yaml
405
397
  - key: artifacts.derived.skills
406
- path: artifacts/derived/skills.md
407
- kind: produced # produce-method (derived) read from source.from: command
408
- zone: artifacts
398
+ path: data/artifacts/derived/skills.md
399
+ kind: produced # produce-method (derived) read from source.from: external
400
+ lane: artifacts
409
401
  owner: automation:catalog-skills
410
402
  source:
411
- from: command
403
+ from: external
412
404
  command: "rake catalog:skills" # informational; external automation invokes it
413
405
  sources: # dotted keys OR repo-relative paths
414
406
  - knowledge.projects
@@ -419,7 +411,7 @@ A derived entry that is produced by a build tool *outside* textus — `rake`, `j
419
411
 
420
412
  **`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `doctor`'s `generator_drift` output can carry a hint about how to regenerate.
421
413
 
422
- **Generator-drift contract.** An entry with `source: { from: command }` is reported by `doctor`'s `generator_drift` check as drifted when:
414
+ **Generator-drift contract.** An entry with `source: { from: external }` is reported by `doctor`'s `generator_drift` check as drifted when:
423
415
  - The derived file does not exist, OR
424
416
  - `_meta.generated.at` is missing or unparseable, OR
425
417
  - Any `sources:` element has been modified after `_meta.generated.at`.
@@ -435,13 +427,13 @@ generated:
435
427
 
436
428
  `generated.from` SHOULD match `source.sources` — they're the same list, recorded twice so a diff proves what was actually consumed.
437
429
 
438
- `from: command` and `from: project` are alternatives — exactly one per derived entry. The external automation produces the bytes directly for `from: command`; the in-process projection produces them for `from: project`. Either way the stored form is data; rendering is deferred to publish (§5.3).
430
+ `from: external` and `from: derive` are alternatives — exactly one per derived entry. The external automation produces the bytes directly for `from: external`; the in-process projection produces them for `from: derive`. Either way the stored form is data; rendering is deferred to publish (§5.3).
439
431
 
440
432
  ### 5.3 Publish layer (`publish:`)
441
433
 
442
434
  Rendering and emission are a **publish** concern, orthogonal to acquire (§5.2). `publish:` is always a **list** of targets (ADR 0094). Each element is exactly one of two shapes:
443
435
 
444
- - a **to-target** — `{ to: <path>, template?: <name>, inject_boot?: <bool> }` — emit the entry's data to one repo-relative path;
436
+ - a **to-target** — `{ to: <path>, template?: <name> }` — emit the entry's data to one repo-relative path;
445
437
  - a **tree-target** — `{ tree: <dir> }` — mirror the entry's stored subtree (ADR 0047).
446
438
 
447
439
  The legacy *map* forms — `publish: { to: [...] }` and `publish: { tree: ... }` — and the older top-level `publish_to:` / `publish_tree:` keys are **rejected at load** with a migration message: `publish:` is a list, and a mirror is a `{ tree: }` element of it.
@@ -464,15 +456,15 @@ A **to-target** carries `to:` (required) and optionally `template:` / `inject_bo
464
456
 
465
457
  The vendored Mustache subset for `template:`: `{{var}}` (interpolation), `{{#section}}...{{/section}}` (iteration / truthy block), `{{^inverted}}...{{/inverted}}` (inverted section), `{{!comment}}`. No partials, no lambdas, no HTML escaping (output is raw text). Template recursion depth is bounded at 8; exceeding the limit is an error.
466
458
 
467
- A sentinel is written for each published file at `<store_root>/.run/sentinels/<target-relative-to-repo>.textus-managed.json` (git-ignored runtime state — ADR 0070), recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store's runtime tree rather than beside the consumer file so target directories stay clean, and are regenerated by the next reconcile (via content-identical adoption) rather than committed. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
459
+ A sentinel is written for each published file at `<store_root>/.run/sentinels/<target-relative-to-repo>.textus-managed.json` (git-ignored runtime state — ADR 0070), recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store's runtime tree rather than beside the consumer file so target directories stay clean, and are regenerated by the next drain (via content-identical adoption) rather than committed. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
468
460
 
469
- **Subtree mirror.** A nested entry MAY include a `{ tree: "dir" }` target (see §4). On every reconcile, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/.run/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The reconcile envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `produced` array, plus a `pruned` array listing any orphaned managed files removed on this pass. Targets that would resolve outside the repo root are refused. When a `{ tree: }` target overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
461
+ **Subtree mirror.** A nested entry MAY include a `{ tree: "dir" }` target (see §4). On every drain/serve pass, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/.run/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The convergence envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `produced` array, plus a `pruned` array listing any orphaned managed files removed on this pass. Targets that would resolve outside the repo root are refused. When a `{ tree: }` target overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
470
462
 
471
463
  **Publish presence is a uniform rule across all kinds.** Absent → the entry is terminal data (consumed internally via another entry's `select`, or read via `get`). Present → emit to the listed targets, every kind through one publish path. A `from: command` entry with publish targets emits the bytes the command already wrote into the store; without targets it is a staleness-only signal.
472
464
 
473
- ### 5.4 Intake source (`from: handler`)
465
+ ### 5.4 Intake source (`from: fetch`)
474
466
 
475
- Intake entries acquire their data via `source: { from: handler, ... }` — an external fetch through a registered handler. The `source:` block fully replaces the former `intake:` block; the entry's `kind:` is `produced` and the *intake* produce-method is read from `source.from: handler` (ADR 0095). Like every `source:`, it is acquire-only — a fetched feed is **data**, and if it needs rendering for a consumer that is a publish target's `template:` (§5.3), never the handler's job. textus itself makes no implicit network calls: the handler runs only when `textus reconcile` (the scheduled sweep) or a `hook run` event re-pulls a stale entry past its `source.ttl` — a `get` never runs it (ADR 0089).
467
+ Intake entries acquire their data via `source: { from: handler, ... }` — an external fetch through a registered handler. The `source:` block fully replaces the former `intake:` block; the entry's `kind:` is `produced` and the *intake* produce-method is read from `source.from: handler` (ADR 0095). Like every `source:`, it is acquire-only — a fetched feed is **data**, and if it needs rendering for a consumer that is a publish target's `template:` (§5.3), never the handler's job. textus itself makes no implicit network calls: the handler runs only when `textus drain`/`serve` or a `hook run` event re-pulls a stale entry past its `source.ttl` — a `get` never runs it (ADR 0089).
476
468
 
477
469
  ```yaml
478
470
  - key: feeds.calendar.events
@@ -483,10 +475,10 @@ Intake entries acquire their data via `source: { from: handler, ... }` — an ex
483
475
  handler: ical-events
484
476
  config:
485
477
  url: "https://calendar.google.com/.../basic.ics"
486
- ttl: 6h # re-pull cadence; reconcile re-pulls when past ttl
478
+ ttl: 6h # re-pull cadence; drain/serve re-pulls when past ttl
487
479
  ```
488
480
 
489
- `handler` names a registered `:resolve_handler` hook (see §5.10); `config` is an opaque hash handed to the handler. `ttl` is the re-pull cadence: the `reconcile` sweep (and `hook run`) re-pulls the entry when `now - last_fetched_at > ttl`. A `get` annotates the entry with `stale: true` when past ttl but **never** re-pulls (ADR 0089). Age-based garbage collection of intake entries is separate and orthogonal — declare a `retention:` rule block (§5.11).
481
+ `handler` names a registered `:resolve_handler` hook (see §5.10); `config` is an opaque hash handed to the handler. `ttl` is the re-pull cadence: the `drain`/`serve` sweep (and `hook run`) re-pulls the entry when `now - last_fetched_at > ttl`. A `get` annotates the entry with `stale: true` when past ttl but **never** re-pulls (ADR 0089). Age-based garbage collection of intake entries is separate and orthogonal — declare a `retention:` rule block (§5.11).
490
482
 
491
483
  > **Note:** `list`/`where` paths do **not** annotate freshness — only `get` does. None of them ever re-pull.
492
484
 
@@ -500,7 +492,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
500
492
 
501
493
  **Re-pull paths.** Ingest is system-pushed (ADR 0089) — never triggered by a read:
502
494
 
503
- 1. **Scheduled sweep** — `textus reconcile --as=automation` re-pulls every intake entry past its `source.ttl`: it resolves the entry's `source.handler`, invokes the registered `:resolve_handler` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `reconcile` (`automation` by default). Run it on a cron/timer.
495
+ 1. **Scheduled sweep** — the convergence worker re-pulls every intake entry past its `source.ttl`: it resolves the entry's `source.handler`, invokes the registered `:resolve_handler` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `converge` (`automation` by default). Run `textus serve` as a long-lived daemon (its scheduler seeds re-pull jobs each tick) or `textus drain --as=automation` on a cron/timer (seed-and-exit).
504
496
  2. **Event push** — `textus hook run` invokes a handler for a specific key on an external event (the same `:resolve_handler` path), for sources that announce changes rather than waiting for the sweep.
505
497
 
506
498
  (A third, manual path remains for out-of-band sources: read the `stale` list from `textus pulse` — soonest deadline `next_due_at` — fetch bytes yourself, and store them with `textus put KEY --as=automation --stdin`. `put` only stores bytes; it runs no handler. For per-entry detail read `textus get KEY` and `textus rule_explain KEY`.)
@@ -516,7 +508,7 @@ Proposal entries are full patches authored into the `proposals` queue zone (writ
516
508
  proposal:
517
509
  target_key: working.network.org.bob
518
510
  action: put
519
- frontmatter:
511
+ _meta:
520
512
  name: bob
521
513
  relationship: peer
522
514
  org: acme
@@ -524,7 +516,7 @@ frontmatter:
524
516
  Proposed body content.
525
517
  ```
526
518
 
527
- `proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The remaining frontmatter and body are the proposed new content. A proposal's `target_key` MUST resolve to a `canon` zone; `accept` refuses any other target (`target_is_canon`, ADR 0035).
519
+ `proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The sibling `_meta` block and the body are the proposed new content — a proposal carries the same `{ _meta, body }` envelope shape it intends `accept` to write (ADR 0113). A proposal's `target_key` MUST resolve to a `canon` zone; `accept` refuses any other target (`target_is_canon`, ADR 0035).
528
520
 
529
521
  `textus accept <proposal-key>` is a **transition** (not a capability) that requires the **`author` capability**: the resolved role must hold `author` (the single trust anchor — `human` by default). It copies the patch into the target zone, records provenance (originating proposal key, original role, original timestamp) in the audit log, and removes the proposal entry. The `reject` transition likewise requires `author`. Roles holding only `propose` (e.g. `agent`) can propose but cannot accept or reject.
530
522
 
@@ -614,11 +606,7 @@ evolution:
614
606
 
615
607
  **Override rule:** a role holding the `author` capability (the trust anchor — `human` by default) is permitted to write any `maintained_by` field, regardless of declared owner. The trust anchor overrides agent-maintained fields by design: schema field ownership (`maintained_by:`) makes the boundary explicit, not implicit. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
616
608
 
617
- ### 5.9 Row transforms
618
-
619
- Row transforms are RPC hooks on the `:transform_rows` event. See §5.10.
620
-
621
- ### 5.10 Hooks
609
+ ### 5.9 Hooks
622
610
 
623
611
  This section is the normative event table. For the hook-author's guide (how to define and test hooks), see [`docs/how-to/writing-hooks.md`](docs/how-to/writing-hooks.md).
624
612
 
@@ -661,7 +649,7 @@ end
661
649
  | `:entry_fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
662
650
  | `:entry_fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
663
651
 
664
- The two `:entry_fetch_*` lifecycle events report the progress and failures of intake fetches during `reconcile` / `hook run`.
652
+ The two `:entry_fetch_*` lifecycle events report the progress and failures of intake fetches during `drain`/`serve` / `hook run`.
665
653
 
666
654
  **`:entry_fetch_started`** fires immediately before an intake handler is invoked. `mode:` is `"refresh"`.
667
655
 
@@ -682,7 +670,7 @@ The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pen
682
670
 
683
671
  Each handler runs under `Timeout.timeout(2)`.
684
672
 
685
- ### 5.11 Rules
673
+ ### 5.10 Rules
686
674
 
687
675
  A manifest MAY declare a top-level `rules:` block — a list of rule blocks matched against entry keys by glob. Each block carries one or more slots:
688
676
 
@@ -692,7 +680,7 @@ rules:
692
680
  retention: { ttl: 90d, action: archive }
693
681
 
694
682
  - match: feeds.calendar.**
695
- intake_handler_allowlist: [ical-events]
683
+ handler_permit: [ical-events]
696
684
 
697
685
  - match: proposals.**
698
686
  guard:
@@ -703,9 +691,9 @@ rules:
703
691
 
704
692
  | Slot | Type | Meaning |
705
693
  |---|---|---|
706
- | `retention` | `{ ttl, action: drop\|archive }` | Age-based garbage collection (ADR 0093). `action` is `drop` (delete the entry) or `archive` (copy to `<store>/archive/<relative-path>` then delete). Age is measured from `_meta.last_fetched_at` (intake entries) when present, else the leaf file's modification time. **Destructive — applied only on the `reconcile` sweep (Phase 2), never on a write or read.** Orthogonal to production: an intake entry may declare both `source: { ..., ttl: 1h }` (re-pull cadence) and a `retention: { ttl: 90d, action: archive }` rule. `retention:` on a `derived` entry is rejected at load. |
707
- | `intake_handler_allowlist` | list of strings | Constrains which `source.handler:` names may be used by intake entries matched by this block. Enforced by `textus doctor`. |
708
- | `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `key_delete`, `key_mv`, `accept`, `reject`, `reconcile`). Predicate names are drawn from the closed vocabulary (`zone_writable_by`, `schema_valid`, `author_held`, `target_is_canon`, `etag_match`, `fresh_within`); parameterized predicates use `{ name: param }` form, e.g. `{ fresh_within: "1h" }`. Enforced — the transition refuses (`guard_failed`) if any predicate fails; the topology refusal keeps the `write_forbidden` code. |
694
+ | `retention` | `{ ttl, action: drop\|archive }` | Age-based garbage collection (ADR 0093). `action` is `drop` (delete the entry) or `archive` (copy to `<store>/archive/<relative-path>` then delete). Age is measured from `_meta.last_fetched_at` (intake entries) when present, else the leaf file's modification time. **Destructive — applied only on the convergence sweep (the destructive phase of `drain`/`serve`), never on a write or read.** Orthogonal to production: an intake entry may declare both `source: { ..., ttl: 1h }` (re-pull cadence) and a `retention: { ttl: 90d, action: archive }` rule. `retention:` on a `derived` entry is rejected at load. |
695
+ | `handler_permit` | list of strings | Constrains which `source.handler:` names may be used by intake entries matched by this block. Enforced by `textus doctor`. |
696
+ | `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `key_delete`, `key_mv`, `accept`, `reject`, `converge`). Predicate names are drawn from the closed vocabulary (`zone_writable_by`, `schema_valid`, `author_held`, `target_is_canon`, `etag_match`, `fresh_within`); parameterized predicates use `{ name: param }` form, e.g. `{ fresh_within: "1h" }`. Enforced — the transition refuses (`guard_failed`) if any predicate fails; the topology refusal keeps the `write_forbidden` code. |
709
697
 
710
698
  The `retention:` slot handles age-based GC only. Write-trigger strategy for derived entries (`on_write: sync|async`) is declared on the entry's own `source:` block (§5.2.1), not in `rules:`. Generator/build drift — a derived entry whose sources changed since its `generated.at` — is reported by the `textus doctor` `generator_drift` check rather than any rule slot.
711
699
 
@@ -715,7 +703,7 @@ The `retention:` slot handles age-based GC only. Write-trigger strategy for deri
715
703
 
716
704
  **Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{retention, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
717
705
 
718
- ### 5.12 Storage formats
706
+ ### 5.11 Storage formats
719
707
 
720
708
  An entry's `format:` selects a storage strategy. All strategies expose the same `parse(bytes) → {_meta, body, content}` and `serialize(meta:, body:, content:) → bytes` contract. The store, audit, etag, and projection layers operate on the parsed shape; only (de)serialization differs.
721
709
 
@@ -811,7 +799,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
811
799
  **Field rules:**
812
800
  - `protocol` MUST be the exact string `textus/3`.
813
801
  - `key` MUST be the canonical resolved key.
814
- - `zone` MUST be one of the zones declared in the manifest (`knowledge`, `notebook`, `feeds`, `proposals`, `artifacts` in the default Setup-1 scaffold).
802
+ - `zone` MUST be one of the lanes declared in the manifest (`knowledge`, `notebook`, `feeds`, `proposals`, `artifacts` in the default Setup-1 scaffold).
815
803
  - `path` MUST be an absolute filesystem path.
816
804
  - `format` MUST be one of `markdown`, `json`, `yaml`, `text` (§5.12). Absent envelopes are treated as `markdown` for back-compat.
817
805
  - `body` is the raw on-disk bytes as a UTF-8 string for every format.
@@ -875,7 +863,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
875
863
  | `put K --stdin --as=R` | write (stores the stdin JSON; runs no handler — ADR 0089) | per zone |
876
864
  | `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
877
865
  | `key delete K --if-etag=E --as=R` | write | per zone |
878
- | `reconcile [--prefix=K] [--zone=Z] [--dry-run]` | write | `reconcile`-holder (typically `automation`) |
866
+ | `drain [--prefix=K] [--zone=Z]` | write | `converge`-holder (typically `automation`) |
867
+ | `serve [--poll=SECS]` | write (long-lived daemon) | `converge`-holder (typically `automation`) |
868
+ | `jobs [--state=ready\|leased\|done\|failed] [--action=retry\|purge] [--job-id=ID]` | read | any |
879
869
  | `accept K --as=human` | write | `author`-holder (typically `human`) |
880
870
  | `reject K --as=human` | write | `author`-holder (typically `human`) |
881
871
  | `init` | write | `human` |
@@ -883,15 +873,15 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
883
873
  | `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
884
874
  | `key uid K` | read | any |
885
875
 
886
- **`textus boot` envelope extras.** In addition to zones, entries, hooks, write flows, and the `cli_verbs` catalog, the boot envelope includes an `agent_quickstart` block synthesized from the manifest's role capabilities:
876
+ **`textus boot` envelope extras.** In addition to lanes, entries, hooks, write flows, and the `cli_verbs` catalog, the boot envelope includes an `agent_quickstart` block synthesized from the manifest's role capabilities:
887
877
 
888
878
  ```json
889
879
  {
890
880
  "agent_quickstart": {
891
881
  "read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
892
882
  "write_verbs": ["accept", "key_delete", "key_mv", "propose", "put", "reject"],
893
- "writable_zones": ["proposals"],
894
- "propose_zone": "proposals",
883
+ "writable_lanes": ["proposals"],
884
+ "propose_lane": "proposals",
895
885
  "latest_seq": 1842
896
886
  }
897
887
  }
@@ -899,7 +889,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
899
889
 
900
890
  `read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its retention/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`doctor`, nor `freshness` (the Ruby-only internal lifecycle scan, ADR 0085) (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
901
891
 
902
- The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `reconcile` is also on MCP (ADR 0076, ADR 0087): it is caller-agnostic and self-elevatingits materialize phase always runs as the manifest's `reconcile`-capable actor regardless of the calling role, grants no authority over content (materialization is a pure, idempotent function of already-accepted canon, ADR 0070), and the whole two-phase pass is serialized by a shared single-writer maintenance lock across all transports so a concurrent CLI, reactive, or background pass cannot collide with an MCP-triggered one.
892
+ The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `drain` is also on MCP (ADR 0076, ADR 0087, ADR 0110): it is caller-agnostic and its produce jobs self-elevatematerialization always runs as the manifest's `converge`-capable actor regardless of the calling role, granting no authority over content (materialization is a pure, idempotent function of already-accepted canon, ADR 0070); the destructive retention sweep runs as the caller. Each produce job self-acquires the single-writer build lock, so a concurrent CLI, reactive, or background pass cannot collide with an MCP-triggered one — a held lock is a graceful soft-miss (ADR 0110).
903
893
 
904
894
  `latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
905
895
 
@@ -930,11 +920,11 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
930
920
 
931
921
  `if_etag` is optional on both `put` and `key_delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted, the write is unconditional (last-writer-wins).
932
922
 
933
- The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) against each intake entry's `source.ttl`. ADR 0085 removed the standalone `freshness` verb that used to render these rows; the scan is now Ruby-only (consumed by `pulse` and the hook context), and human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `source.ttl` and retention policy). `textus reconcile` produces all in-scope derived entries and re-pulls stale intake entries in Phase 1, then runs the destructive `retention:` sweep in Phase 2 (§5.11); `--dry-run` prints the plan (`would_produce` plus `would_drop`/`would_archive`) without executing.
923
+ The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) against each intake entry's `source.ttl`. ADR 0085 removed the standalone `freshness` verb that used to render these rows; the scan is now Ruby-only (consumed by `pulse` and the hook context), and human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `source.ttl` and retention policy). `textus drain` enqueues the convergence jobs — produce every in-scope derived entry, re-pull every stale intake entry, and a retention sweep — then drains the queue to empty (§5.11). Convergence is async-only (ADR 0110): there is no `--dry-run`.
934
924
 
935
925
  `textus accept K --as=human` promotes a pending entry into its target zone: it copies the patch body into the target key, deletes the pending entry, and writes one audit line per side (§audit). Only a role holding the `author` capability (the trust anchor — `human` by default) may invoke `accept`.
936
926
 
937
- `textus reconcile [--prefix=K] [--zone=Z] [--dry-run]` is the manual full maintenance pass (ADR 0093). It runs two phases under **one** shared maintenance lock: **Phase 1 (non-destructive)** produce ALL in-scope derived entries' data (always rebuild — pure/idempotent, unchanged sources write nothing) and re-pull every intake entry past its `source.ttl`; nested entries with a `{ tree: }` publish target are also produced. **Phase 2 (destructive)** — the `retention:` sweep: drop or archive entries past their `retention.ttl` (§5.11). Phase 1 self-elevates to the manifest's `reconcile`-capable actor (`automation` by default) — materialization is a pure function of already-accepted canon and grants no authority over content. Phase 2 runs as the **caller** (gated as the caller's own `key_delete` authority), never self-elevates. `--dry-run` returns a plan without mutating: `would_produce` (the keys Phase 1 would write) alongside `would_drop`/`would_archive` from Phase 2. An apply returns `produced`, `produce_failed`, `dropped`, `archived`, `failed`, and `health`. In day-to-day use derived entries stay fresh **reactively** — a canon write re-materializes dependent derived entries inline (the per-write reactive rebuild is "reconcile narrowed to rdeps ∩ derived") — so `reconcile` is the on-demand catch-all, not a step in the normal write loop.
927
+ `textus drain [--prefix=K] [--zone=Z]` is the manual converge-and-exit pass (ADR 0093, ADR 0110). It seeds a closed allow-list of jobs into the durable file-backed queue (`Ports::Queue` under `.textus/.run/queue/`) and runs a worker until the queue is empty: a **`materialize`** job per in-scope derived / publish entry (always rebuild — pure/idempotent, unchanged sources write nothing; nested `{ tree: }` targets included), a **`re-pull`** job per intake entry past its `source.ttl`, and a single **`sweep`** job for the destructive `retention:` GC (§5.11). Authority is frozen at enqueue: `materialize`/`re-pull` self-elevate inside `Produce::Engine` to the manifest's `converge`-capable actor (`automation` by default) — materialization is a pure function of already-accepted canon and grants no authority over content while `sweep` runs as the **caller** (gated as the caller's own `key_delete` authority), never self-elevating. Drain is single-pass and **serial**: each produce job self-acquires the non-reentrant build lock, so a held lock is a graceful soft-miss. `drain` returns `{ ok, completed, failed, health }` and exits non-zero if any job dead-lettered; per-key produce failures surface as `:produce_failed` events. There is no `--dry-run` (materialization is async-only). `textus serve` is the same worker as a long-lived daemon, whose `Scheduler` seeds TTL re-pull + sweep each tick; `textus jobs` inspects/retries/purges the queue. In day-to-day use derived entries stay fresh **reactively** — a canon write enqueues a `materialize` job for each dependent derived entry (the reactive scope is "converge narrowed to rdeps ∩ derived"), processed by a running `serve` or the next `drain` — so `drain` is the on-demand / CI catch-all, not a step in the normal write loop.
938
928
 
939
929
  `textus init` scaffolds a fresh `.textus/` tree (manifest, zones, schemas, audit log) under the current directory with a default manifest. Customize by editing `.textus/manifest.yaml` after init.
940
930
 
@@ -950,7 +940,7 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
950
940
 
951
941
  ## 10.2 `textus doctor`
952
942
 
953
- `textus doctor` returns a health-check envelope: `{ "protocol": "textus/3", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `protocol_version`, `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `hooks`, `intake_registration`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `rule_ambiguity`, `handler_allowlist`, `fetch_locks`, `proposal_targets`, `publish.tree_index_overlap`. Additional registered `:validate` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
943
+ `textus doctor` returns a health-check envelope: `{ "protocol": "textus/3", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `protocol_version`, `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `intake_registration`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `rule_ambiguity`, `handler_permit`, `fetch_locks`, `proposal_targets`, `publish.tree_index_overlap`, `generator_drift`. Additional registered `:validate` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
954
944
 
955
945
  ## 11. Versioning
956
946
 
@@ -984,16 +974,16 @@ Given the `person` schema and a `put` whose frontmatter omits `relationship`, th
984
974
  Given a manifest entry `intake.notes` with `kind: produced` and `source: { from: handler, handler: h, ttl: 1h }` (the intake produce-method read from `source.from`), and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus pulse --output=json` lists `intake.notes` in its `stale` array (the lifecycle scan classifies it `expired`). The scan is pure: producing this verdict does NOT trigger a re-pull.
985
975
 
986
976
  **Fixture E — Projection produce:**
987
- Given a manifest entry `artifacts.derived.skills` with `kind: produced` and `source: { from: project, select: knowledge.projects, ... }` (the derived produce-method read from `source.from`), `textus reconcile --prefix=artifacts.derived.skills` produces the derived entry's **data** on disk (serialized per `format:`) matching the projected shape. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so re-running with unchanged sources reproduces it byte-for-byte and writes nothing.
977
+ Given a manifest entry `artifacts.derived.skills` with `kind: produced` and `source: { from: project, select: knowledge.projects, ... }` (the derived produce-method read from `source.from`), `textus drain --prefix=artifacts.derived.skills` produces the derived entry's **data** on disk (serialized per `format:`) matching the projected shape. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so re-running with unchanged sources reproduces it byte-for-byte and writes nothing.
988
978
 
989
979
  **Fixture F — Mustache render at publish:**
990
- Given a produced entry with a to-target `{ to:, template: <name> }`, `textus reconcile` renders the entry's stored data through the template and emits a file whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization). Two to-targets with different templates produce different bytes from the one entry.
980
+ Given a produced entry with a to-target `{ to:, template: <name> }`, `textus drain` renders the entry's stored data through the template and emits a file whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization). Two to-targets with different templates produce different bytes from the one entry.
991
981
 
992
982
  **Fixture G — Copy publish:**
993
- Given a manifest entry with a templateless to-target `publish: [{ to: <path> }]`, a successful `textus reconcile` for that entry leaves a plain file at `<path>` whose contents are the entry's content re-serialized without `_meta` (byte-identical to a clean consumer config), accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `reconcile` is idempotent.
983
+ Given a manifest entry with a templateless to-target `publish: [{ to: <path> }]`, a successful `textus drain` for that entry leaves a plain file at `<path>` whose contents are the entry's content re-serialized without `_meta` (byte-identical to a clean consumer config), accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `drain` is idempotent.
994
984
 
995
985
  **Fixture H — Audit log format:**
996
- Every successful write verb (`put`, `key_delete`, `reconcile`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
986
+ Every successful write verb (`put`, `key_delete`, `key_mv`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). Convergence (`drain`/`serve`) writes through these same verbs (`put` for a produced entry, `key_delete` for a swept one), so it appends per the underlying write, not under a distinct `drain` verb. No write produces zero or multiple lines per key.
997
987
 
998
988
  **Fixture I — Pending → accept:**
999
989
  Given a proposal entry `proposals.knowledge.self.patch` proposing a change to `knowledge.identity.self`, `textus accept proposals.knowledge.self.patch --as=human` copies the patch body into the target key, deletes the proposal entry, and appends two audit lines (one for the target write, one for the proposals delete) in that order.
@@ -1016,7 +1006,7 @@ Given a proposal entry `proposals.knowledge.self.patch` proposing a change to `k
1016
1006
 
1017
1007
  ## 13.1 Layered architecture (internal)
1018
1008
 
1019
- Textus internals are organized into four one-way layers — **Interface** (`cli/`, `mcp/`) → **Application** (`application/` use cases) → **Domain** (`domain/` pure values) → **Infrastructure** (`infra/` adapters). Each layer imports only from the one beneath it. Plugin authors touch only the Hook DSL and the manifest YAML; the layering is internal and may evolve.
1009
+ Textus internals are organized as one-way layers — **Surfaces** (`surfaces/cli/`, `surfaces/mcp/`) → **Contract** (`contract/`) → **Dispatch** (`dispatch/`) → **Manifest + Core + Ports + Step** (domain and adapters). Each layer imports only from layers to its right. Plugin authors touch only the Step DSL and the manifest YAML; the layering is internal and may evolve.
1020
1010
 
1021
1011
  See [`docs/architecture/README.md`](docs/architecture/README.md) for an ASCII diagram and the full read-path walkthrough.
1022
1012
 
@@ -1048,116 +1038,3 @@ A `textus/3` implementation MAY:
1048
1038
  - Provide alternate output formats (`--output=yaml`, `--output=table`) for human use.
1049
1039
  - Support additional schema field types beyond §6, marked as `vendor:<name>` extensions.
1050
1040
 
1051
- ## 16. Migrating from textus/2
1052
-
1053
- textus does not ship a built-in textus/2 → textus/3 migrator. The historical upgrade path (via the one-shot `textus migrate` in the 0.11.x line) is recorded in `CHANGELOG.md` §0.11.0. `textus doctor` refuses a store still declaring `version: textus/2`. The textus/2 → textus/3 rename table is kept below for reference.
1054
-
1055
- **Vocabulary summary** (textus/2 → textus/3 rename table, for reference):
1056
-
1057
- | Category | textus/2 | textus/3 (current) |
1058
- |---|---|---|
1059
- | Actor | `ai` | `agent` |
1060
- | Actor | `script` | `automation` |
1061
- | Actor | `build` | `automation` |
1062
- | Zone | `inbox` | `intake` |
1063
- | Manifest | `writable_by:` | (removed — authority is derived from `kind:` × `can:`) |
1064
- | Manifest | `policies:` | `rules:` |
1065
- | Manifest | `handler_allowlist:` | `intake_handler_allowlist:` |
1066
- | Manifest | `promote_requires:` | `guard: { accept: [...] }` |
1067
- | Manifest | `projection:` | `source: { from: project, select: ..., ... }` (flat fields) |
1068
- | Manifest | `generator:` | `source: { from: command, ... }` |
1069
- | Hook event | `:intake` | `:resolve_handler` |
1070
- | Hook event | `:reduce` | `:transform_rows` |
1071
- | Hook event | `:check` | `:validate` |
1072
- | Hook event | `:put` | `:entry_written` |
1073
- | Hook event | `:deleted` | `:entry_deleted` |
1074
- | Hook event | `:refreshed` | `:entry_fetched` |
1075
- | Hook event | `:built` | `:entry_produced` |
1076
- | Hook event | `:accepted` | `:proposal_accepted` |
1077
- | Hook event | `:reject` | `:proposal_rejected` |
1078
- | Hook event | `:published` | `:entry_published` |
1079
- | Hook event | `:mv` | `:entry_renamed` |
1080
- | Hook event | `:loaded` | `:store_loaded` |
1081
- | Hook event | `:refresh_began` | `:entry_fetch_started` |
1082
- | Hook event | `:refresh_detached` | `:fetch_backgrounded` |
1083
- | Hook event | `:refresh_failed` | `:entry_fetch_failed` |
1084
- | Hook DSL | `Textus.hook(ev, name)` / sugar | `Textus.on(ev, name)` |
1085
- | Source field | `projection.reduce:` | `source.transform:` |
1086
- | `_meta` key | `reducer` | `transform` |
1087
- | CLI flag | `--format=json` (envelope) | `--output=json` |
1088
- | CLI verb | `refresh-stale` | `fetch all` |
1089
- | CLI verb | `policy list/explain` | `rule list/explain` |
1090
-
1091
- **Hook migration.** Legacy event names / DSL methods must be renamed to the textus/3 forms above before a hook will load; see `CHANGELOG.md` §0.11.0 for the full event-rename detail.
1092
-
1093
- ### 16.1 Breaking changes in 0.31.0 (capability-based roles)
1094
-
1095
- 0.31.0 replaced declared per-zone write policies with **derived** authority and renamed the `refresh` transition to `fetch`. These keys/values are no longer accepted:
1096
-
1097
- | Removed / renamed (≤ 0.30) | 0.31.0 form |
1098
- |---|---|
1099
- | `zones[*].write_policy:` | (removed) authority is derived: `role.can ⊇ { verb_for(zone.kind) }` |
1100
- | `roles[*].kind:` (`accept_authority`/`generator`/`proposer`/`runner`) | `roles[*].can:` (subset of `propose`, `author`, `fetch`, `build`) |
1101
- | Actors `runner`, `builder` | `automation` (`can: [fetch, build]`) by default |
1102
- | `rules[*].refresh:` slot | `rules[*].fetch:` slot |
1103
- | CLI `textus refresh` / `refresh stale` | `textus fetch` / `fetch all` |
1104
- | `_meta.last_refreshed_at` | `_meta.last_fetched_at` |
1105
- | Promotion predicate `:human_accept` / `:accept_authority_signed` | `:author_signed` |
1106
- | Envelope `refreshing` | `fetching` |
1107
-
1108
- A manifest still declaring `write_policy:` or a role `kind:` is rejected at load. There is no compatibility alias — the breaking change requires a new wire-compatible manifest. (Wire string `textus/3` is unchanged: capabilities are a manifest concept and never appear on the wire.)
1109
-
1110
- ### 16.2 Breaking changes in 0.33.0 (workspace/keep + Setup-1 scaffold)
1111
-
1112
- 0.33.0 adds the fifth coordination primitive (`workspace` zone-kind + `keep` capability), renames the capability `accept` → `author` (and predicate `accept_signed` → `author_signed`), renames zone-kind `origin` → `canon`, and renames the default scaffold zones to the Setup-1 names. These changes affect **manifest files and tooling** only — the `textus/3` wire format is **UNCHANGED** (envelope shape, audit-log schema, key grammar, and the `version: textus/3` field are all identical to 0.32.x).
1113
-
1114
- **Renames (manifest and predicate vocabulary):**
1115
-
1116
- | Removed / renamed (≤ 0.32) | 0.33.0 form |
1117
- |---|---|
1118
- | Zone-kind `origin` | `canon` |
1119
- | Capability `accept` | `author` |
1120
- | Promotion predicate `accept_signed` | `author_signed` |
1121
- | Default scaffold zone `identity` | `knowledge` (identity keys live under `knowledge.identity.*`) |
1122
- | Default scaffold zone `working` | `knowledge` (merged into the same `canon` zone) |
1123
- | Default scaffold zone `intake` | `feeds` |
1124
- | Default scaffold zone `review` | `proposals` |
1125
- | Default scaffold zone `output` | `artifacts` |
1126
-
1127
- **New in 0.33.0:**
1128
-
1129
- | Addition | Detail |
1130
- |---|---|
1131
- | Zone-kind `workspace` | Agent's own durable lane. Required capability: `keep`. Bytes never auto-promote; climb to `canon` only via propose→accept. |
1132
- | Capability `keep` | Authorizes writes to `workspace` zones. Default scaffold: `agent` holds `[propose, keep]`. |
1133
- | Default scaffold zone `notebook` | `kind: workspace`, default owner `agent`. |
1134
- | `owner:` on a zone | OPTIONAL, INFORMATIONAL — not enforced in 0.33.0 (owner-scoped enforcement is deferred). |
1135
- | `desc:` on a zone | OPTIONAL — surfaces as the `purpose` field in `textus boot` zone rows. |
1136
-
1137
- **Clarification (not a breaking change):** `accept` and `reject` are **transition verbs** (CLI commands), not capabilities. Both require the `author` capability. This has always been true; 0.33.0 makes it explicit by removing `accept` from the capability vocabulary.
1138
-
1139
- A manifest declaring `kind: origin` or capability `accept` (in a `can:` list) is rejected at load.
1140
-
1141
- ### 16.3 Breaking changes in 0.35.0 (proposal target-canon + `author_held`)
1142
-
1143
- 0.35.0 constrains a proposal's target to a `canon` zone and renames the anchor-gate predicate. No `textus/3` wire change; no manifest-schema change.
1144
-
1145
- **Renames (predicate vocabulary):**
1146
-
1147
- | Removed / renamed (≤ 0.34) | 0.35.0 form |
1148
- |---|---|
1149
- | Promotion predicate `author_signed` | `author_held` |
1150
-
1151
- **New in 0.35.0:**
1152
-
1153
- | Addition | Detail |
1154
- |---|---|
1155
- | Floor predicate `target_is_canon` | On the `accept` base guard. A proposal's `target_key` MUST resolve to a `canon` zone; `accept` refuses any other target with `guard_failed` naming `target_is_canon`. Floor-only — not relaxable via `rules[].guard`. |
1156
- | `doctor` check `proposal_targets` | Warns on queued proposals whose `target_key` is non-canon (`proposal.target_not_canon`) or unresolvable (`proposal.target_unresolved`). |
1157
-
1158
- A `rules[].guard` block referencing the predicate by its old name `author_signed` is rejected at load (unknown predicate).
1159
-
1160
- ---
1161
-
1162
- **Spec word count target:** <2700 words (allowance widened to fit vocabulary axes intro + migration section).
1163
- **Reviewed against community-testing checklist (idea file §"Community-testing"):** ✅ implementable in a day in TS/Python (four concepts: manifest, schema, envelope, staleness check); ✅ conformance fixtures A–I; ✅ "Why not X?" section present (incl. why no execution); ✅ name picked.