textus 0.50.0 → 0.52.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +176 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +15 -17
  8. data/lib/textus/call.rb +1 -1
  9. data/lib/textus/cli/runner.rb +15 -10
  10. data/lib/textus/cli/verb/get.rb +1 -3
  11. data/lib/textus/cli/verb/hook_run.rb +1 -1
  12. data/lib/textus/cli/verb/put.rb +4 -20
  13. data/lib/textus/cli/verb/serve.rb +19 -0
  14. data/lib/textus/cli.rb +1 -3
  15. data/lib/textus/dispatcher.rb +3 -3
  16. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  17. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  18. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  19. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  20. data/lib/textus/doctor/check/sentinels.rb +2 -2
  21. data/lib/textus/doctor/check/templates.rb +13 -11
  22. data/lib/textus/doctor.rb +0 -2
  23. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  24. data/lib/textus/domain/freshness/verdict.rb +28 -6
  25. data/lib/textus/domain/freshness.rb +4 -33
  26. data/lib/textus/domain/jobs/job.rb +58 -0
  27. data/lib/textus/domain/jobs/registry.rb +37 -0
  28. data/lib/textus/domain/policy/base_guards.rb +1 -1
  29. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  30. data/lib/textus/domain/policy/publish_target.rb +34 -0
  31. data/lib/textus/domain/policy/retention.rb +29 -0
  32. data/lib/textus/domain/policy/source.rb +73 -0
  33. data/lib/textus/domain/retention/sweep.rb +57 -0
  34. data/lib/textus/domain/retention.rb +11 -0
  35. data/lib/textus/errors.rb +4 -4
  36. data/lib/textus/hooks/builtin.rb +5 -5
  37. data/lib/textus/hooks/catalog.rb +7 -7
  38. data/lib/textus/hooks/context.rb +5 -10
  39. data/lib/textus/init/templates/machine_intake.rb +4 -4
  40. data/lib/textus/init.rb +47 -47
  41. data/lib/textus/jobs/handlers.rb +62 -0
  42. data/lib/textus/jobs/scheduler.rb +36 -0
  43. data/lib/textus/jobs/seeder.rb +57 -0
  44. data/lib/textus/key/matching.rb +24 -0
  45. data/lib/textus/layout.rb +8 -0
  46. data/lib/textus/maintenance/drain.rb +42 -0
  47. data/lib/textus/maintenance/retention/apply.rb +52 -0
  48. data/lib/textus/maintenance/serve.rb +30 -0
  49. data/lib/textus/maintenance/worker.rb +74 -0
  50. data/lib/textus/manifest/capabilities.rb +1 -1
  51. data/lib/textus/manifest/data.rb +18 -3
  52. data/lib/textus/manifest/entry/base.rb +28 -9
  53. data/lib/textus/manifest/entry/nested.rb +3 -4
  54. data/lib/textus/manifest/entry/parser.rb +25 -21
  55. data/lib/textus/manifest/entry/produced.rb +56 -0
  56. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  57. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  58. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  59. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  60. data/lib/textus/manifest/entry/validators.rb +0 -1
  61. data/lib/textus/manifest/policy.rb +16 -4
  62. data/lib/textus/manifest/resolver.rb +10 -4
  63. data/lib/textus/manifest/rules.rb +37 -36
  64. data/lib/textus/manifest/schema/keys.rb +98 -0
  65. data/lib/textus/manifest/schema/validator.rb +324 -0
  66. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  67. data/lib/textus/manifest/schema.rb +27 -247
  68. data/lib/textus/manifest.rb +5 -3
  69. data/lib/textus/mcp/server.rb +1 -1
  70. data/lib/textus/ports/audit_log.rb +6 -0
  71. data/lib/textus/ports/build_lock.rb +6 -0
  72. data/lib/textus/ports/clock.rb +4 -3
  73. data/lib/textus/ports/produce_on_write_subscriber.rb +73 -0
  74. data/lib/textus/ports/publisher.rb +11 -7
  75. data/lib/textus/ports/queue.rb +130 -0
  76. data/lib/textus/produce/acquire/handler.rb +29 -0
  77. data/lib/textus/produce/acquire/intake.rb +130 -0
  78. data/lib/textus/produce/acquire/projection.rb +127 -0
  79. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  80. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  81. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  82. data/lib/textus/produce/acquire/serializer.rb +17 -0
  83. data/lib/textus/produce/engine.rb +95 -0
  84. data/lib/textus/produce/events.rb +36 -0
  85. data/lib/textus/produce/render.rb +23 -0
  86. data/lib/textus/projection.rb +17 -6
  87. data/lib/textus/read/deps.rb +3 -3
  88. data/lib/textus/read/freshness.rb +61 -31
  89. data/lib/textus/read/get.rb +20 -102
  90. data/lib/textus/read/jobs.rb +31 -0
  91. data/lib/textus/read/rdeps.rb +3 -3
  92. data/lib/textus/read/rule_explain.rb +41 -23
  93. data/lib/textus/read/rule_list.rb +25 -8
  94. data/lib/textus/read/validate_all.rb +14 -0
  95. data/lib/textus/role.rb +2 -1
  96. data/lib/textus/schemas.rb +8 -0
  97. data/lib/textus/store.rb +1 -0
  98. data/lib/textus/version.rb +1 -1
  99. data/lib/textus/write/enqueue.rb +50 -0
  100. data/lib/textus/write/put.rb +1 -1
  101. metadata +35 -30
  102. data/lib/textus/builder/pipeline.rb +0 -88
  103. data/lib/textus/builder/renderer/json.rb +0 -45
  104. data/lib/textus/builder/renderer/markdown.rb +0 -24
  105. data/lib/textus/builder/renderer/text.rb +0 -14
  106. data/lib/textus/builder/renderer/yaml.rb +0 -45
  107. data/lib/textus/builder/renderer.rb +0 -17
  108. data/lib/textus/cli/verb/boot.rb +0 -14
  109. data/lib/textus/cli/verb/build.rb +0 -15
  110. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  111. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  112. data/lib/textus/domain/freshness/policy.rb +0 -18
  113. data/lib/textus/domain/lifecycle.rb +0 -83
  114. data/lib/textus/domain/outcome.rb +0 -10
  115. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  116. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  117. data/lib/textus/domain/staleness.rb +0 -29
  118. data/lib/textus/maintenance/tend.rb +0 -110
  119. data/lib/textus/manifest/entry/derived.rb +0 -67
  120. data/lib/textus/manifest/entry/intake.rb +0 -31
  121. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  122. data/lib/textus/mcp/tools.rb +0 -14
  123. data/lib/textus/ports/fetch/detached.rb +0 -52
  124. data/lib/textus/ports/fetch/lock.rb +0 -44
  125. data/lib/textus/write/build.rb +0 -90
  126. data/lib/textus/write/fetch_events.rb +0 -42
  127. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  128. data/lib/textus/write/fetch_worker.rb +0 -127
  129. data/lib/textus/write/intake_fetch.rb +0 -25
  130. data/lib/textus/write/materializer.rb +0 -51
data/SPEC.md CHANGED
@@ -21,11 +21,11 @@
21
21
  - [5. Zones and capability-based write gates](#5-zones-and-capability-based-write-gates)
22
22
  - [5.1 Role resolution](#51-role-resolution)
23
23
  - [5.1.1 Capabilities](#511-capabilities)
24
- - [5.2 Compute layer (derived entries)](#52-compute-layer-derived-entries)
25
- - [5.2.1 Projection compute](#521-projection-compute-kind-projection)
26
- - [5.2.2 External compute](#522-external-compute-kind-external)
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)
27
27
  - [5.3 Publish layer](#53-publish-layer-publish)
28
- - [5.4 Intake](#54-intake-declared-fetched-via-registered-intake-handler)
28
+ - [5.4 Intake source (`from: handler`)](#54-intake-source-from-handler)
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)
@@ -74,9 +74,9 @@ implementation is the bug.
74
74
 
75
75
  ## 1. What textus is
76
76
 
77
- 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 compute, and copy-based publish targets.
77
+ 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
78
 
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. Derived entries are computed from other entries via pure projections and a vendored Mustache template engine, then optionally published to repo-relative paths as byte-for-byte file copies. The CLI surface (`textus get/put/list/where/schema/build/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
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/drain/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
80
80
 
81
81
  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
82
 
@@ -84,11 +84,11 @@ You **shape your own memory structure** inside `.textus/`. The protocol manages
84
84
 
85
85
  textus/3 names its concepts along six axes. Reviewers who internalize these can map any part of the spec to the right category:
86
86
 
87
- - **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `fetch`, `build`).
87
+ - **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `converge`).
88
88
  - **Place** — where data lives: zones such as `knowledge`, `notebook`, `feeds`, `proposals`, `artifacts`.
89
89
  - **Thing** — what is stored: entries, fields, keys.
90
- - **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `fetch`, `build`, …).
91
- - **Event** — what gets fired after an operation: hook event names, split into RPC events (`:resolve_intake`, `:transform_rows`, `:validate`) and pub-sub events (`:entry_put`, `:build_completed`, …).
90
+ - **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `drain`, `serve`, …).
91
+ - **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
92
  - **Rule** — constraints declared in the top-level `rules:` array of the manifest.
93
93
 
94
94
  ### 1.2 The five layers
@@ -98,9 +98,9 @@ textus is organized as five composable layers. Each layer has a single responsib
98
98
  | Layer | Name | Responsibility |
99
99
  |---|---|---|
100
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 `feeds` zone in the default scaffold; any `quarantine` zone, writable by a role with `fetch`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
102
- | L3 | **Compute** | Pure transforms from store entries to derived entries. Projections (select/pluck/sort/limit/format) plus a vendored Mustache template subset. No shell execution. |
103
- | L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish: { to: [...] }`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/.run/sentinels/<target-rel-path>.textus-managed.json` (git-ignored runtime state) records the source, sha256, and `mode: "copy"`. |
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 `converge`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
102
+ | 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
+ | 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
104
  | 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. |
105
105
 
106
106
  ## 2. Goals and non-goals
@@ -111,7 +111,7 @@ textus is organized as five composable layers. Each layer has a single responsib
111
111
  - Schema-validated frontmatter using YAML schemas as data.
112
112
  - Capability-based write gates (roles hold capabilities; write authority per zone is derived from the role's capabilities and the zone's kind).
113
113
  - Optimistic concurrency via ETags.
114
- - Pure declarative compute: derived entries computed from projections + Mustache, no shell-out.
114
+ - Pure declarative data sources: derived entries acquire their data from projections over store keys, no shell-out; presentation (Mustache) is a separate publish concern.
115
115
  - Publish derived entries to well-known paths as body-only plain files.
116
116
  - Plain-file backend — consumers can also read raw if they prefer.
117
117
 
@@ -139,16 +139,15 @@ The root is `.textus/` at the project working directory. A typical tree:
139
139
  zones/ # ALL user content lives here
140
140
  knowledge/ # zone: knowledge (kind: canon — author-holders write; knowledge.identity.* is the identity convention)
141
141
  notebook/ # zone: notebook (kind: workspace — keep-holders write; agent's own durable lane)
142
- feeds/ # zone: feeds (kind: quarantine — fetch-holders write)
143
142
  proposals/ # zone: proposals (kind: queue — propose-holders write)
144
- artifacts/ # zone: artifacts (kind: derivedbuild-holders write)
143
+ artifacts/ # zone: artifacts (kind: machineconverge-holders write; external inputs artifacts.feeds.* + computed outputs artifacts.derived.*)
145
144
  ```
146
145
 
147
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.
148
147
 
149
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.
150
149
 
151
- `.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `key_delete`, `accept`, and `build`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
150
+ `.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).
152
151
 
153
152
  ### 3.1 Store location precedence
154
153
 
@@ -171,7 +170,7 @@ version: textus/3
171
170
  roles:
172
171
  - { name: human, can: [author, propose] }
173
172
  - { name: agent, can: [propose] }
174
- - { name: automation, can: [fetch, build] }
173
+ - { name: automation, can: [converge] }
175
174
 
176
175
  zones:
177
176
  - name: knowledge
@@ -180,12 +179,10 @@ zones:
180
179
  kind: workspace
181
180
  owner: agent # optional, informational — agent's own lane
182
181
  desc: "agent's durable working memory; bytes climb to knowledge only via propose→accept"
183
- - name: feeds
184
- kind: quarantine
185
182
  - name: proposals
186
183
  kind: queue
187
184
  - name: artifacts
188
- kind: derived
185
+ kind: machine # machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
189
186
 
190
187
  entries:
191
188
  - key: knowledge.identity.self
@@ -204,11 +201,11 @@ entries:
204
201
  path: artifacts/catalogs/people.md
205
202
  zone: artifacts
206
203
  schema: null
207
- owner: automation:build
204
+ owner: automation:converge
208
205
 
209
206
  rules:
210
- - match: feeds.**
211
- lifecycle: { ttl: 6h, on_expire: warn }
207
+ - match: artifacts.feeds.**
208
+ retention: { ttl: 6h, action: archive }
212
209
 
213
210
  audit:
214
211
  max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
@@ -223,16 +220,16 @@ Zone names are conventional — write authority comes from each zone's declared
223
220
 
224
221
  | `format` | Path extension | `template:` | `schema:` |
225
222
  |------------|-----------------------------|------------------------|-----------|
226
- | `markdown` | `.md` (or appended if absent) | required for derived | optional |
223
+ | `markdown` | `.md` (or appended if absent) | required for produced | optional |
227
224
  | `json` | `.json` required | optional (escape hatch) | optional (top-level keys) |
228
225
  | `yaml` | `.yaml` or `.yml` required | optional (escape hatch) | optional (top-level keys) |
229
- | `text` | `.txt` or no extension | required for derived | MUST be null |
226
+ | `text` | `.txt` or no extension | required for produced | MUST be null |
230
227
 
231
- For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema. Each matching file is enumerated as its own key, with the key segments derived from the path relative to the entry (extension stripped). A nested entry that instead mirrors a whole directory of files to a consumer path — without enumerating any of them as keys — uses `publish: { tree: }` (below); its files are opaque payload. (The former `index_filename:` directory-keyed enumeration was removed in 0.43.0 — ADR 0053.)
228
+ For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema. Each matching file is enumerated as its own key, with the key segments derived from the path relative to the entry (extension stripped). A nested entry that instead mirrors a whole directory of files to a consumer path — without enumerating any of them as keys — uses a `{ tree: }` publish target (below); its files are opaque payload. (The former `index_filename:` directory-keyed enumeration was removed in 0.43.0 — ADR 0053.)
232
229
 
233
- **The `publish:` block (ADR 0052).** Publishing is configured by one typed `publish:` block with exactly one of two sub-keys `publish: { to: [...] }` (file fan-out, §5.3) **xor** `publish: { tree: "dir" }` (subtree mirror, below). Setting both is an error. The legacy top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message.
230
+ **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.
234
231
 
235
- **Subtree mirror (`publish: { tree: }`).** A nested manifest entry MAY declare `publish: { tree: "dir" }` 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 build the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). When a `publish.tree` target directory overlaps a `derived` entry's `publish.to` (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.
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 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.
236
233
 
237
234
  ```yaml
238
235
  - key: working.skills
@@ -241,11 +238,11 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
241
238
  schema: skill
242
239
  nested: true
243
240
  publish:
244
- tree: "skills"
241
+ - { tree: "skills" }
245
242
  ignore: ["*.tmp", ".DS_Store"]
246
243
  ```
247
244
 
248
- **`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When `textus build` materializes the entry, it merges the `textus boot` envelope (§9) into the projection data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside its projected rows. The flag is rejected at manifest load on (a) non-derived entries or (b) derived entries without a `template:` — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
245
+ **`inject_boot:` (a publish-target flag).** A to-target with a `template:` MAY declare `inject_boot: true`. When `textus drain` 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.
249
246
 
250
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.
251
248
 
@@ -259,35 +256,36 @@ The kind→verb mapping is closed:
259
256
  |---|---|---|
260
257
  | `canon` | `author` | Authored truth — only the trust anchor writes directly. |
261
258
  | `workspace` | `keep` | Agent's own durable lane — bytes never auto-promote; climb to `canon` only via propose→accept. |
262
- | `quarantine` | `fetch` | External bytes pending validation. |
259
+ | `machine` | `converge` | Machine-maintained: external bytes pending validation + outputs computed from other zones. |
263
260
  | `queue` | `propose` | Proposals awaiting promotion. |
264
- | `derived` | `build` | Computed from other zones. |
261
+
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 `converge`, because machine-maintained bytes (external inputs and computed outputs alike) are kept current by the same convergence sweep (`drain`/`serve`).
265
263
 
266
264
  `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.
267
265
 
268
- Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[fetch, build]`):
266
+ Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[converge]`):
269
267
 
270
268
  | Zone | `kind` | Required capability | Writable by (default) | Use case |
271
269
  |---|---|---|---|---|
272
270
  | `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network. `knowledge.identity.*` is the identity key convention. |
273
271
  | `notebook` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
274
- | `feeds` | `quarantine` | `fetch` | `automation` | Declared external inputs (calendar, feeds, scraped pages). Fetched by external automation; never by humans or agents directly. |
275
272
  | `proposals` | `queue` | `propose` | `agent`, `human` | Proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `knowledge`. |
276
- | `artifacts` | `derived` | `build` | `automation` | Computed outputs (catalogs, indexes, published context). Written via `textus build`. |
273
+ | `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`. |
277
274
 
278
275
  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).
279
276
 
280
277
  Every zone MUST declare a `kind:` describing its role in the data-flow graph.
281
278
  The vocabulary is closed: `canon` (authored truth), `workspace` (agent's own
282
- durable lane), `quarantine` (external bytes pending validation), `queue`
283
- (proposals awaiting promotion), `derived` (computed from other zones). A
284
- manifest MUST declare at most one `queue` zone. Because authority is derived, a
285
- manifest is rejected at load if it declares a zone whose required verb is held
286
- by **no** declared role (`derived` ⇒ a role with `build`, `queue` ⇒ `propose`,
287
- `quarantine` ⇒ `fetch`, `workspace` ⇒ `keep`, `canon` ⇒ `author`). Coordination
288
- is keyed off the declared kind: a zone is derived only if it declares
289
- `kind: derived`, and proposals route to the declared `queue` zone — there is no
290
- name-based fallback. A manifest with a kind-less zone is rejected at load.
279
+ durable lane), `machine` (machine-maintained: external bytes pending validation
280
+ + outputs computed from other zones), `queue` (proposals awaiting promotion). A
281
+ manifest MUST declare at most one `queue` zone and at most one `machine` zone.
282
+ Because authority is derived, a manifest is rejected at load if it declares a
283
+ zone whose required verb is held by **no** declared role (`machine` ⇒ a role with
284
+ `converge`, `queue` ⇒ `propose`, `workspace` ⇒ `keep`, `canon` ⇒ `author`).
285
+ Coordination is keyed off the declared kind: a zone is machine-maintained only if
286
+ it declares `kind: machine`, and proposals route to the declared `queue` zone —
287
+ there is no name-based fallback. A manifest with a kind-less zone is rejected at
288
+ load.
291
289
 
292
290
  ### 5.1 Role resolution
293
291
 
@@ -304,7 +302,7 @@ The effective role for any CLI invocation is resolved in this order; the first m
304
302
  |---|---|---|
305
303
  | `human` | `[author, propose]` | Interactive user at a terminal; the single trust anchor. |
306
304
  | `agent` | `[propose]` | Long-running AI or LLM process; stages proposals. |
307
- | `automation` | `[fetch, build]` | Scheduled or one-shot scripts: fetch external sources, build derived outputs. |
305
+ | `automation` | `[converge]` | Scheduled or one-shot scripts: keep the `machine` lane current — pull external sources in and materialize computed outputs. |
308
306
 
309
307
  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`.
310
308
 
@@ -312,7 +310,7 @@ Every successful write records the resolved role and a wall-clock timestamp in `
312
310
 
313
311
  #### 5.1.1 Capabilities
314
312
 
315
- Roles declare **capabilities** — verbs from a closed five-element set. A
313
+ Roles declare **capabilities** — verbs from a closed four-element set. A
316
314
  manifest declares a `roles:` block mapping each role name to the capabilities
317
315
  it holds via `can:`:
318
316
 
@@ -320,21 +318,26 @@ it holds via `can:`:
320
318
  roles:
321
319
  - { name: owner, can: [author, propose] }
322
320
  - { name: proposer, can: [propose] }
323
- - { name: fetcher, can: [fetch] }
324
- - { name: compiler, can: [build] }
321
+ - { name: machine, can: [converge] }
325
322
  - { name: keeper, can: [keep] }
326
323
  ```
327
324
 
328
- Capability allow-list: `propose`, `author`, `keep`, `fetch`, `build`. Each verb is the
329
- required capability for exactly one zone-kind:
325
+ Capability allow-list: `propose`, `author`, `keep`, `converge`. The mapping from
326
+ zone-kind to its required capability is a **bijection** (ADR 0091, which folded
327
+ the former `quarantine` + `derived` kinds back into one `machine` kind — undoing
328
+ the two-kind split of ADR 0090): each capability authorizes exactly one
329
+ zone-kind:
330
330
 
331
331
  | Capability | Authorizes writes to zone-kind |
332
332
  |---|---|
333
333
  | `author` | `canon` |
334
334
  | `keep` | `workspace` |
335
335
  | `propose` | `queue` |
336
- | `fetch` | `quarantine` |
337
- | `build` | `derived` |
336
+ | `converge` | `machine` |
337
+
338
+ A manifest naming a folded capability — `ingest` or `build`, or the pre-0088
339
+ spelling `fetch` — in a `can:` list is rejected at load with a hint pointing to
340
+ `converge` (ADR 0090, 0091, 0111).
338
341
 
339
342
  `author` is the single **trust anchor**: **at most one role may hold `author`**
340
343
  (a manifest declaring two or more is rejected at load). The `accept` and
@@ -349,7 +352,7 @@ When the `roles:` block is omitted, the default mapping applies:
349
352
  |---|---|
350
353
  | `human` | `[author, propose]` |
351
354
  | `agent` | `[propose, keep]` |
352
- | `automation` | `[fetch, build]` |
355
+ | `automation` | `[converge]` |
353
356
 
354
357
  Wire protocol `textus/3` is unchanged — capabilities are a manifest/semantics
355
358
  concept and never appear on the wire.
@@ -361,63 +364,62 @@ predicate keys on the `author` capability and is named `author_held` (it passes
361
364
  when the acting role holds `author`). See §5.11 for composing extra predicates via
362
365
  `rules[].guard:`.
363
366
 
364
- ### 5.2 Compute layer (derived entries)
367
+ ### 5.2 Source layer (produced entries)
365
368
 
366
- Derived entries live in a `derived` zone (writable by a role holding `build`; `automation` by default) — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry declares a `compute:` block with a `kind:` discriminator.
369
+ Produced entries live in a `machine` zone (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 (`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).
367
370
 
368
- #### 5.2.1 Projection compute (`kind: projection`)
371
+ #### 5.2.1 Projection source (`from: project`)
372
+
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.
369
374
 
370
375
  ```yaml
371
- - key: output.catalogs.people
372
- zone: output
373
- compute:
374
- kind: projection
375
- select: working.network.org # prefix OR [list of prefixes]
376
+ - key: artifacts.derived.people
377
+ kind: produced # produce-method (derived) read from source.from: project
378
+ zone: artifacts
379
+ source:
380
+ from: project
381
+ select: knowledge.network.org # prefix OR [list of prefixes]
376
382
  pluck: [name, relationship, org]
377
383
  sort_by: name # optional
378
384
  limit: 1000 # default 1000, max 1000
379
- format: yaml-list-in-md # one of: list, hash, yaml-list-in-md, json, markdown-table
385
+ format: json # one of: list, hash, yaml-list-in-md, json, markdown-table
380
386
  transform: rank_by_recency # optional — names a :transform_rows hook
381
- template: people.mustache # optional; if absent, format determines body
387
+ on_write: async # sync | async (default async)
382
388
  ```
383
389
 
384
- `select` is either a single dotted-key prefix or a list of prefixes. Every entry whose key starts with one of those prefixes is included. `pluck` names the frontmatter fields to retain in the projection result. `sort_by` is optional; when absent, entries are sorted by key. `limit` is bounded at 1000 entries (hard cap); requests above 1000 are rejected.
385
-
386
- `format` controls the body serialization when no template is supplied. Permitted values: `list`, `hash`, `yaml-list-in-md`, `json`, `markdown-table`.
390
+ `select` is either a single dotted-key prefix or a list of prefixes. Every entry whose key starts with one of those prefixes is included. `pluck` names the frontmatter fields to retain. `sort_by` is optional; when absent, entries are sorted by key. `limit` is bounded at 1000 entries (hard cap); requests above 1000 are rejected.
387
391
 
388
- `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.
392
+ `format` controls how the acquired data is serialized for storage. Permitted values: `list`, `hash`, `yaml-list-in-md`, `json`, `markdown-table`.
389
393
 
390
- If `template` is given, it names a Mustache template under `.textus/templates/`. textus implements a deliberately restricted Mustache subset:
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.
391
395
 
392
- - `{{var}}` variable interpolation.
393
- - `{{#section}}...{{/section}}` — section (iteration / truthy block).
394
- - `{{^inverted}}...{{/inverted}}` — inverted section.
395
- - `{{!comment}}` — comment.
396
+ `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.
396
397
 
397
- No partials. No lambdas. No HTML escaping (output is raw text, intended for Markdown). Template recursion depth is bounded at 8; exceeding the limit is an error.
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).
398
399
 
399
- #### 5.2.2 External compute (`kind: external`)
400
+ #### 5.2.2 External source (`from: command`)
400
401
 
401
- A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `compute: { kind: 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 0079 keeps it out of the `lifecycle:` unification and ADR 0085 keeps it out of the internal `freshness` scan — it is a `doctor` health check.)
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.)
402
403
 
403
404
  ```yaml
404
- - key: output.catalogs.skills
405
- path: output/catalogs/skills.md
406
- zone: output
405
+ - key: artifacts.derived.skills
406
+ path: artifacts/derived/skills.md
407
+ kind: produced # produce-method (derived) read from source.from: command
408
+ zone: artifacts
407
409
  owner: automation:catalog-skills
408
- compute:
409
- kind: external
410
+ source:
411
+ from: command
410
412
  command: "rake catalog:skills" # informational; external automation invokes it
411
413
  sources: # dotted keys OR repo-relative paths
412
- - working.projects
413
- - working.network
414
+ - knowledge.projects
415
+ - knowledge.network
414
416
  ```
415
417
 
416
418
  **`sources:`** is a list. Each element is either a dotted key prefix (matched against manifest entries) or a filesystem path (relative to the repo root, or absolute). For each key prefix, every matching entry's file mtime is checked. For each path, file or directory mtime is checked.
417
419
 
418
420
  **`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.
419
421
 
420
- **Generator-drift contract.** An entry with `compute: { kind: external }` is reported by `doctor`'s `generator_drift` check as drifted when:
422
+ **Generator-drift contract.** An entry with `source: { from: command }` is reported by `doctor`'s `generator_drift` check as drifted when:
421
423
  - The derived file does not exist, OR
422
424
  - `_meta.generated.at` is missing or unparseable, OR
423
425
  - Any `sources:` element has been modified after `_meta.generated.at`.
@@ -428,64 +430,65 @@ A derived entry that is produced by a build tool *outside* textus — `rake`, `j
428
430
  generated:
429
431
  by: "rake catalog:skills"
430
432
  at: "2026-05-25T12:00:00Z"
431
- from: [working.projects, working.network]
433
+ from: [knowledge.projects, knowledge.network]
432
434
  ```
433
435
 
434
- `generated.from` SHOULD match `compute.sources` — they're the same list, recorded twice so a diff proves what was actually consumed.
436
+ `generated.from` SHOULD match `source.sources` — they're the same list, recorded twice so a diff proves what was actually consumed.
435
437
 
436
- `kind: external` and `kind: projection` are alternatives — exactly one per entry. Templates are not required for `kind: external`: the external automation produces the bytes directly.
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).
437
439
 
438
440
  ### 5.3 Publish layer (`publish:`)
439
441
 
440
- Publishing is configured by one typed `publish:` block with exactly one sub-key (ADR 0052): `to:` (file fan-out) **xor** `tree:` (subtree mirror). Setting both is an error; the legacy top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message.
442
+ 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:
441
443
 
442
- A derived entry MAY declare `publish: { to: [...] }`, listing one or more destination paths relative to the project root:
444
+ - a **to-target** `{ to: <path>, template?: <name>, inject_boot?: <bool> }` emit the entry's data to one repo-relative path;
445
+ - a **tree-target** — `{ tree: <dir> }` — mirror the entry's stored subtree (ADR 0047).
446
+
447
+ 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.
443
448
 
444
449
  ```yaml
445
450
  publish:
446
- to:
447
- - CLAUDE.md
448
- - .ai/instructions.md
451
+ - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
452
+ - { to: AGENTS.md, template: orientation.mustache } # same data, its own render
453
+ - { to: .mcp.json } # no template → copy data verbatim
454
+ - { tree: skills/ } # subtree mirror (ADR 0047)
449
455
  ```
450
456
 
451
- When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
457
+ A **to-target** carries `to:` (required) and optionally `template:` / `inject_boot:`:
458
+
459
+ - **No `template:`** → publish the entry's **content**. For a structured data format (`json`/`yaml`) the content is re-serialized *without* textus's `_meta` block, so a config like `.mcp.json` stays a clean consumer file; for any other / opaque format, a literal byte-copy. (This is "publish the content," not "copy the stored envelope.")
460
+ - **`template:` present** → render the entry's data through the named Mustache template under `.textus/templates/` and publish the rendered bytes. One dataset can feed differently-formatted outputs by giving each to-target its own template.
461
+ - **`inject_boot:`** (default `false`) → merge the `textus boot` payload into the render data for *this target*. It is per-target and only meaningful alongside a `template:`.
462
+
463
+ **Published artifacts are clean content.** textus's `_meta` provenance (`from`/`reduce`, §5.12) stays in the **stored** entry and is never emitted — a verbatim copy strips it on re-serialize, a rendered template surfaces provenance only if it explicitly references `_meta`. There is no entry-level / publish `provenance:` flag (rejected at load); provenance is carried in one place, the stored data's `_meta`.
464
+
465
+ 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
+
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 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.
452
468
 
453
- 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 build (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.
469
+ **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).
454
470
 
455
- **Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `to:` (see §4). On every build, 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 build envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused. When a `publish.tree` target overlaps a `derived` entry's `publish.to` (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).
471
+ **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.
456
472
 
457
- ### 5.4 Intake (declared, refreshed via registered intake handler)
473
+ ### 5.4 Intake source (`from: handler`)
458
474
 
459
- Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when a read-through `textus get KEY` encounters a stale entry whose `lifecycle` rule says `on_expire: refresh`. The declaration is data only:
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 drain`/`serve` or a `hook run` event re-pulls a stale entry past its `source.ttl` a `get` never runs it (ADR 0089).
460
476
 
461
477
  ```yaml
462
478
  - key: feeds.calendar.events
479
+ kind: produced # produce-method (intake) read from source.from: handler
463
480
  zone: feeds
464
- intake:
481
+ source:
482
+ from: handler
465
483
  handler: ical-events
466
484
  config:
467
485
  url: "https://calendar.google.com/.../basic.ics"
468
-
469
- rules:
470
- - match: feeds.calendar.**
471
- lifecycle:
472
- ttl: 6h
473
- on_expire: refresh # refresh | warn | drop | archive
474
- budget_ms: 500 # bound the in-process refresh (default: 500)
486
+ ttl: 6h # re-pull cadence; drain/serve re-pulls when past ttl
475
487
  ```
476
488
 
477
- `handler` names a registered `:resolve_intake` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the handler. The freshness budget (`ttl`, `on_expire`, `budget_ms`) lives in a top-level **`rules:`** block matched by key glob (§5.11).
478
-
479
- #### `on_expire:` semantics
480
-
481
- `on_expire:` declares what happens when `get` encounters an expired (past-TTL) intake entry. `get` is **read-through on every surface** (CLI, Ruby, MCP): it returns the freshest obtainable envelope, refreshing on an expired verdict per the entry's `lifecycle` rule and degrading to a pure on-disk read for keys with no lifecycle rule (ADR 0062). The value lives on the matching policy block, not on the entry. For intake entries the only valid actions are `refresh` and `warn` (`drop`/`archive` apply to stored entries and are enforced by `doctor` via `lifecycle.action_invalid`).
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 `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).
482
490
 
483
- | Value | Behaviour |
484
- |---|---|
485
- | `warn` (default) | Return the entry immediately with `stale: true`, `stale_reason:` populated, and `fetching: false`. No blocking. |
486
- | `refresh` | Block the `get` call, run the intake handler in-process under a `budget_ms` deadline (default 500 ms), write the result, and return the fresh envelope. If the handler does not finish in time, return the stale envelope (with `stale: true`, `fetching: true`) and let the refresh complete in the background. Fires `:fetch_backgrounded` when the deadline is exceeded. |
487
-
488
- > **Note:** `list`/`where` paths do **not** annotate freshness — only `get` does.
491
+ > **Note:** `list`/`where` paths do **not** annotate freshness — only `get` does. None of them ever re-pull.
489
492
 
490
493
  In intake mode the handler MUST return one of three shapes, all normalized by the store into its internal `{_meta, body, content}` representation (§5.12):
491
494
 
@@ -495,12 +498,14 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
495
498
 
496
499
  **Built-in intake handlers.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured `_meta`/body. Built-ins do not perform I/O themselves — the caller (or an outer hook) is responsible for supplying bytes.
497
500
 
498
- **Refresh paths.** Two are supported:
501
+ **Re-pull paths.** Ingest is system-pushed (ADR 0089) — never triggered by a read:
502
+
503
+ 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
+ 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.
499
505
 
500
- 1. **In-process** a read-through `textus get KEY --as=automation` on a stale entry whose rule says `on_expire: refresh` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `fetch` (`automation` by default).
501
- 2. **External automation** — a cron job or agent harness reads the `stale` list from `textus pulse` (the soonest deadline is `next_due_at`), fetches the sources of the keys reported stale out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`. (`pulse` derives `stale`/`next_due_at` from the internal lifecycle scan; ADR 0085 removed the standalone `freshness` verb. For per-entry detail — ttl, age, on_expire action — read `textus get KEY` and `textus rule_explain KEY`.)
506
+ (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`.)
502
507
 
503
- Both paths share the same write gate, audit-log entry, and `:entry_fetched` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
508
+ All paths share the same write gate, audit-log entry, and `:entry_fetched` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
504
509
 
505
510
  ### 5.5 Pending / accept workflow
506
511
 
@@ -626,11 +631,11 @@ The subdirectory layout under `hooks/` is organizational only; the registered ev
626
631
  ```ruby
627
632
  # Canonical form — works for every event:
628
633
  Textus.hook do |reg|
629
- reg.on(:resolve_intake, :my_source) { |caps:, config:, args:, **| … }
630
- reg.on(:transform_rows, :rank_by_recency) { |caps:, rows:, **| … }
631
- reg.on(:validate, :storage_writable) { |caps:| … }
632
- reg.on(:entry_put, :audit, keys: ["working.*"]) { |ctx:, key:, envelope:, **| … }
633
- reg.on(:file_published, :git_add, keys: ["derived.*"]) { |ctx:, target:, **| `git add #{target.shellescape}` }
634
+ reg.on(:resolve_handler, :my_source) { |caps:, config:, args:, **| … }
635
+ reg.on(:transform_rows, :rank_by_recency) { |caps:, rows:, **| … }
636
+ reg.on(:validate, :storage_writable) { |caps:| … }
637
+ reg.on(:entry_written, :audit, keys: ["working.*"]) { |ctx:, key:, envelope:, **| … }
638
+ reg.on(:entry_published, :git_add, keys: ["derived.*"]) { |ctx:, target:, **| `git add #{target.shellescape}` }
634
639
  end
635
640
  ```
636
641
 
@@ -640,30 +645,27 @@ end
640
645
 
641
646
  | Event | Mode | Args | Return | Failure |
642
647
  |-------------------------|---------|-----------------------------------------------------------|-----------------------|---------------|
643
- | `:resolve_intake` | rpc | caps:, config:, args: | {_meta:, body:} | aborts op |
648
+ | `:resolve_handler` | rpc | caps:, config:, args: | {_meta:, body:} | aborts op |
644
649
  | `:transform_rows` | rpc | caps:, rows:, config: | rows array | aborts op |
645
650
  | `:validate` | rpc | caps: | issues array | aborts doctor |
646
- | `:entry_put` | pubsub | ctx:, key:, envelope: | (discarded) | logged |
651
+ | `:entry_written` | pubsub | ctx:, key:, envelope: | (discarded) | logged |
647
652
  | `:entry_deleted` | pubsub | ctx:, key: | (discarded) | logged |
648
653
  | `:entry_fetched` | pubsub | ctx:, key:, envelope:, change: | (discarded) | logged |
649
- | `:build_completed` | pubsub | ctx:, key:, envelope:, sources: | (discarded) | logged |
654
+ | `:entry_produced` | pubsub | ctx:, key:, envelope:, sources: | (discarded) | logged |
650
655
  | `:proposal_accepted` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
651
- | `:file_published` | pubsub | ctx:, key:, envelope:, source:, target: | (discarded) | logged |
656
+ | `:entry_published` | pubsub | ctx:, key:, envelope:, source:, target: | (discarded) | logged |
652
657
  | `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
653
658
  | `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
654
659
  | `:store_loaded` | pubsub | ctx: | (discarded) | logged |
655
660
  | `:session_opened` | pubsub | ctx:, role:, cursor: | (discarded) | logged |
656
- | `:fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
657
- | `:fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
658
- | `:fetch_backgrounded` | pubsub | ctx:, key:, started_at:, budget_ms: | (discarded) | logged |
661
+ | `:entry_fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
662
+ | `:entry_fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
659
663
 
660
- The three `:fetch_*` lifecycle events report the progress and failures of background (timed_sync) fetches.
664
+ The two `:entry_fetch_*` lifecycle events report the progress and failures of intake fetches during `drain`/`serve` / `hook run`.
661
665
 
662
- **`:fetch_started`** fires immediately before an intake handler is invoked. `mode:` is one of `"sync"` or `"timed_sync"`.
666
+ **`:entry_fetch_started`** fires immediately before an intake handler is invoked. `mode:` is `"refresh"`.
663
667
 
664
- **`:fetch_failed`** fires when an intake handler raises. `error_class:` is the exception class name string; `error_message:` is `e.message`.
665
-
666
- **`:fetch_backgrounded`** fires when a `timed_sync` fetch exceeds its budget and is handed off to a background thread. `started_at:` is an ISO-8601 UTC string; `budget_ms:` is the configured deadline as an integer.
668
+ **`:entry_fetch_failed`** fires when an intake handler raises. `error_class:` is the exception class name string; `error_message:` is `e.message`.
667
669
 
668
670
  **Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
669
671
 
@@ -674,7 +676,7 @@ Declaring `store:` instead of `caps:` in an RPC callable will pass registration
674
676
 
675
677
  The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry. For `:session_opened`, no key — it fires once per MCP connection at `initialize` with the connection's resolved `role:` and boot `cursor:` (ADR 0075); distinct from `:store_loaded`, which fires once per process at `Store#initialize` under the default role.
676
678
 
677
- **RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`intake.handler: NAME`, `compute.transform: NAME`). Failure or timeout aborts the calling operation.
679
+ **RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`source.handler: NAME` for `:resolve_handler`, `source.transform: NAME` for `:transform_rows`). Failure or timeout aborts the calling operation.
678
680
 
679
681
  **Pub-sub mode** — zero or more handlers per event. All matching handlers fire. The `keys:` option restricts a handler to keys matching one of the given globs (`File.fnmatch?` rules). Absence of `keys:` fires on every event of that type. Handler failures and 2s timeouts are logged to `audit.log` as `event_error` rows; they NEVER abort the triggering operation.
680
682
 
@@ -687,10 +689,9 @@ A manifest MAY declare a top-level `rules:` block — a list of rule blocks matc
687
689
  ```yaml
688
690
  rules:
689
691
  - match: feeds.**
690
- lifecycle: { ttl: 6h, on_expire: warn }
692
+ retention: { ttl: 90d, action: archive }
691
693
 
692
694
  - match: feeds.calendar.**
693
- lifecycle: { ttl: 30m, on_expire: refresh, budget_ms: 800 }
694
695
  intake_handler_allowlist: [ical-events]
695
696
 
696
697
  - match: proposals.**
@@ -702,21 +703,17 @@ rules:
702
703
 
703
704
  | Slot | Type | Meaning |
704
705
  |---|---|---|
705
- | `lifecycle` | `{ ttl, on_expire, budget_ms? }` | Unified age policy (ADR 0079). `on_expire` is `refresh` (re-pull intake), `warn` (flag on read), `drop` (delete), or `archive` (copy to `<store>/archive/<relative-path>` then delete). Non-destructive actions (`refresh`/`warn`) are applied lazily on `get`; destructive actions (`drop`/`archive`) only on the `tend` sweep. `refresh` is valid only for intake entries; `drop`/`archive` only for stored entries (`doctor` `lifecycle.action_invalid` enforces). Age is measured from `_meta.last_fetched_at` (intake) when present, else the leaf file's modification time. `budget_ms` (optional) bounds a `refresh` to a deadline, returning the stale envelope and refreshing in the background when exceeded. |
706
- | `intake_handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
707
- | `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`, `fetch`). 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. |
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 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. |
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`, `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. |
708
709
 
709
- The `lifecycle:` slot unifies the former `fetch:` (intake freshness) and
710
- `retention:` (leaf pruning) slots into one age policy (ADR 0079). Generator/build
711
- drift — a derived entry whose sources changed since its `generated.at` — is
712
- dependency-based, not age-based, and is reported by the `textus doctor`
713
- `generator_drift` check rather than this slot.
710
+ 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.
714
711
 
715
712
  **Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
716
713
 
717
- **Resolution.** For each key textus computes a `RuleSet { handler_allowlist, guard, lifecycle }` by walking every block whose `match` matches the key, ranked by specificity. **Per slot, the most specific block wins.** Two blocks of equal specificity that match the same key and fill the same slot is a manifest error reported by `textus doctor` (`rule_ambiguity`).
714
+ **Resolution.** For each key textus computes a `RuleSet { intake_handler_allowlist, guard, retention }` by walking every block whose `match` matches the key, ranked by specificity. **Per slot, the most specific block wins.** Two blocks of equal specificity that match the same key and fill the same slot is a manifest error reported by `textus doctor` (`rule_ambiguity`).
718
715
 
719
- **Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{lifecycle, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
716
+ **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).
720
717
 
721
718
  ### 5.12 Storage formats
722
719
 
@@ -822,9 +819,9 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
822
819
  - `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
823
820
  - `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
824
821
  - `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
825
- - `stale` is `true` when the entry's TTL has elapsed and the data has not yet been refreshed; `false` otherwise. Only populated for entries matched by a `lifecycle:` rule slot (typically `feeds` / quarantine zone); always `false` elsewhere.
822
+ - `stale` is `true` when the entry's `source.ttl` has elapsed and the entry has not yet been re-pulled; `false` otherwise. Only populated for `intake` entries (those with `source: { from: handler, ttl: ... }`); always `false` for non-intake entries.
826
823
  - `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_fetched"`), or `null` when `stale` is `false`.
827
- - `fetching` is `true` when an `on_expire: refresh` background refresh is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
824
+ - `fetching` is `true` when a background re-pull is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
828
825
 
829
826
  > **Note:** `list`/`where` envelopes do **not** include `stale`, `stale_reason`, or `fetching` — freshness annotation is only provided by `get`.
830
827
 
@@ -863,7 +860,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
863
860
  |---|---|---|
864
861
  | `list [--prefix=K] [--zone=Z]` | read | any |
865
862
  | `where K` | read | any |
866
- | `get K [--no-fetch]` | read (read-through by default: refresh-on-stale per the entry's `lifecycle` rule when `on_expire: refresh`, degrades to a pure read; `--no-fetch` / `{fetch:false}` for an explicit pure on-disk read) | any |
863
+ | `get K` | read (a pure on-disk read annotated with a freshness verdict; never refreshes ADR 0089) | any |
867
864
  | `schema show K` | read | any |
868
865
  | `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
869
866
  | `blame KEY` | read | any |
@@ -875,11 +872,12 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
875
872
  | `doctor [--check=NAME[,NAME]] [--output=json]` | read | any |
876
873
  | `boot [--output=json]` | read | any |
877
874
  | `pulse [--since=N]` | read | any |
878
- | `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
875
+ | `put K --stdin --as=R` | write (stores the stdin JSON; runs no handler — ADR 0089) | per zone |
879
876
  | `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
880
877
  | `key delete K --if-etag=E --as=R` | write | per zone |
881
- | `build [--prefix=K] [--dry-run]` | write | `build`-holder (typically `automation`) |
882
- | `tend [--prefix=K] [--zone=Z] [--dry-run] --as=ROLE` | write | per zone (role must write the matched zone) |
878
+ | `drain [--prefix=K] [--zone=Z]` | write | `converge`-holder (typically `automation`) |
879
+ | `serve [--poll=SECS]` | write (long-lived daemon) | `converge`-holder (typically `automation`) |
880
+ | `jobs [--state=ready\|leased\|done\|failed] [--action=retry\|purge] [--job-id=ID]` | read | any |
883
881
  | `accept K --as=human` | write | `author`-holder (typically `human`) |
884
882
  | `reject K --as=human` | write | `author`-holder (typically `human`) |
885
883
  | `init` | write | `human` |
@@ -901,9 +899,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
901
899
  }
902
900
  ```
903
901
 
904
- `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 lifecycle/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).
902
+ `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).
905
903
 
906
- 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. `build` is also on MCP (ADR 0076): it is caller-agnostic and self-elevatingit always runs as the manifest's `build`-capable actor regardless of the calling role, grants no authority over content (build is a pure, idempotent function of already-accepted canon, ADR 0070), and is serialized by a shared single-writer lock across all transports so a concurrent CLI or background build cannot collide with an MCP-triggered one.
904
+ 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).
907
905
 
908
906
  `latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
909
907
 
@@ -922,7 +920,7 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
922
920
  }
923
921
  ```
924
922
 
925
- `cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is computed by the internal lifecycle scan (the former `freshness` verb, now Ruby-only — ADR 0085 folded its agent-facing output into `pulse`). `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `contract_etag` is the `sha256:`-prefixed composite content hash of the contract — the manifest plus hooks and schemas (ADR 0074, via ADR 0025) — for cheap change-detection. `next_due_at` is the soonest upcoming lifecycle deadline across entries (ISO-8601, or `null` if none). `hook_errors` lists hook failures recorded since the cursor. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
923
+ `cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is computed by the internal lifecycle scan (the former `freshness` verb, now Ruby-only — ADR 0085 folded its agent-facing output into `pulse`) — it lists intake entries past their `source.ttl`. `pending_review` lists all keys in the queue zone. `doctor` is an `{ok, warn, fail}` count summary. `contract_etag` is the `sha256:`-prefixed composite content hash of the contract — the manifest plus hooks and schemas (ADR 0074, via ADR 0025) — for cheap change-detection. `next_due_at` is the soonest upcoming lifecycle deadline across entries (ISO-8601, or `null` if none). `hook_errors` lists hook failures recorded since the cursor. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
926
924
 
927
925
  **`put` input** (read from stdin when `--stdin` is given):
928
926
 
@@ -934,10 +932,12 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
934
932
 
935
933
  `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).
936
934
 
937
- The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) plus the matched rule's `on_expire` action, against its matched `lifecycle:` rule. 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 `lifecycle:` ttl + `on_expire`). `textus build` consumes its own staleness signal and executes derived entries' projections under a `build`-holding role (`automation` by default); `--dry-run` prints the plan without executing.
935
+ 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`.
938
936
 
939
937
  `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`.
940
938
 
939
+ `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.
940
+
941
941
  `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.
942
942
 
943
943
  `textus schema show K` prints the schema for entry `K`. `textus schema init NAME` writes a stub schema. `textus schema diff NAME` compares the on-disk schema against entries that claim it and prints the deltas. `textus schema migrate NAME --rename=OLD:NEW` rewrites the `_meta` key `OLD` to `NEW` across every entry that uses the named schema, in a single transactional sweep that logs each touched file.
@@ -983,19 +983,19 @@ Given a manifest entry where `key: identity.self` lives in the `identity` zone (
983
983
  Given the `person` schema and a `put` whose frontmatter omits `relationship`, the result is the error envelope with `code: "schema_violation"`, `details.missing: ["relationship"]`, and exit code 1.
984
984
 
985
985
  **Fixture D — Staleness detection:**
986
- Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, lifecycle: { ttl: 1h, on_expire: warn } }]` block 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 refresh.
986
+ 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.
987
987
 
988
- **Fixture E — Projection build:**
989
- Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: projection }` clause selects fields from `working.projects` entries, `textus build derived.catalogs.skills` materializes the derived entry on disk with frontmatter and body matching the projected shape. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so rebuilding with unchanged sources reproduces it byte-for-byte and writes nothing.
988
+ **Fixture E — Projection produce:**
989
+ 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.
990
990
 
991
- **Fixture F — Mustache render:**
992
- Given a derived entry with a `template` clause referencing a `.mustache` file and inputs drawn from other keys, `textus build` produces a body whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization).
991
+ **Fixture F — Mustache render at publish:**
992
+ 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.
993
993
 
994
994
  **Fixture G — Copy publish:**
995
- Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
995
+ 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.
996
996
 
997
997
  **Fixture H — Audit log format:**
998
- Every successful write verb (`put`, `key_delete`, `build`, `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.
998
+ 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.
999
999
 
1000
1000
  **Fixture I — Pending → accept:**
1001
1001
  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.
@@ -1041,7 +1041,7 @@ A `textus/3` implementation MUST:
1041
1041
  - [ ] Refuse writes whose resolved role lacks the capability the target zone-kind requires with `write_forbidden`.
1042
1042
  - [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
1043
1043
  - [ ] Use the error codes in §8 and the exit-code table.
1044
- - [ ] Implement the lifecycle scan behind `pulse` (`stale`/`next_due_at`) and the hook context per §5.11 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|expired|no_policy` (plus the `on_expire` action) without invoking any refresh. (ADR 0085: a Ruby-only internal scan — there is no public `freshness` verb.)
1044
+ - [ ] Implement the lifecycle scan behind `pulse` (`stale`/`next_due_at`) and the hook context per §5.4, §5.11, and §9, walking each intake entry, comparing `now - last_fetched_at` against `source.ttl`, and reporting `fresh|expired|no_policy` without invoking any re-pull. (ADR 0085: a Ruby-only internal scan — there is no public `freshness` verb.)
1045
1045
  - [ ] Pass the conformance fixtures A–I in §12.
1046
1046
 
1047
1047
  A `textus/3` implementation MAY:
@@ -1066,25 +1066,25 @@ textus does not ship a built-in textus/2 → textus/3 migrator. The historical u
1066
1066
  | Manifest | `policies:` | `rules:` |
1067
1067
  | Manifest | `handler_allowlist:` | `intake_handler_allowlist:` |
1068
1068
  | Manifest | `promote_requires:` | `guard: { accept: [...] }` |
1069
- | Manifest | `projection:` | `compute: { kind: projection, ... }` |
1070
- | Manifest | `generator:` | `compute: { kind: external, ... }` |
1071
- | Hook event | `:intake` | `:resolve_intake` |
1069
+ | Manifest | `projection:` | `source: { from: project, select: ..., ... }` (flat fields) |
1070
+ | Manifest | `generator:` | `source: { from: command, ... }` |
1071
+ | Hook event | `:intake` | `:resolve_handler` |
1072
1072
  | Hook event | `:reduce` | `:transform_rows` |
1073
1073
  | Hook event | `:check` | `:validate` |
1074
- | Hook event | `:put` | `:entry_put` |
1074
+ | Hook event | `:put` | `:entry_written` |
1075
1075
  | Hook event | `:deleted` | `:entry_deleted` |
1076
1076
  | Hook event | `:refreshed` | `:entry_fetched` |
1077
- | Hook event | `:built` | `:build_completed` |
1077
+ | Hook event | `:built` | `:entry_produced` |
1078
1078
  | Hook event | `:accepted` | `:proposal_accepted` |
1079
1079
  | Hook event | `:reject` | `:proposal_rejected` |
1080
- | Hook event | `:published` | `:file_published` |
1080
+ | Hook event | `:published` | `:entry_published` |
1081
1081
  | Hook event | `:mv` | `:entry_renamed` |
1082
1082
  | Hook event | `:loaded` | `:store_loaded` |
1083
- | Hook event | `:refresh_began` | `:fetch_started` |
1083
+ | Hook event | `:refresh_began` | `:entry_fetch_started` |
1084
1084
  | Hook event | `:refresh_detached` | `:fetch_backgrounded` |
1085
- | Hook event | `:refresh_failed` | `:fetch_failed` |
1085
+ | Hook event | `:refresh_failed` | `:entry_fetch_failed` |
1086
1086
  | Hook DSL | `Textus.hook(ev, name)` / sugar | `Textus.on(ev, name)` |
1087
- | Compute field | `projection.reduce:` | `compute.transform:` |
1087
+ | Source field | `projection.reduce:` | `source.transform:` |
1088
1088
  | `_meta` key | `reducer` | `transform` |
1089
1089
  | CLI flag | `--format=json` (envelope) | `--output=json` |
1090
1090
  | CLI verb | `refresh-stale` | `fetch all` |