textus 0.51.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +19 -19
  4. data/SPEC.md +41 -39
  5. data/docs/architecture/README.md +9 -9
  6. data/docs/reference/conventions.md +8 -8
  7. data/lib/textus/boot.rb +7 -5
  8. data/lib/textus/cli/runner.rb +2 -2
  9. data/lib/textus/cli/verb/put.rb +1 -1
  10. data/lib/textus/cli/verb/serve.rb +19 -0
  11. data/lib/textus/dispatcher.rb +3 -1
  12. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  13. data/lib/textus/doctor/check/sentinels.rb +2 -2
  14. data/lib/textus/domain/freshness/evaluator.rb +2 -2
  15. data/lib/textus/domain/jobs/job.rb +58 -0
  16. data/lib/textus/domain/jobs/registry.rb +37 -0
  17. data/lib/textus/domain/policy/base_guards.rb +1 -1
  18. data/lib/textus/domain/policy/retention.rb +1 -1
  19. data/lib/textus/domain/policy/source.rb +4 -10
  20. data/lib/textus/errors.rb +2 -2
  21. data/lib/textus/hooks/catalog.rb +0 -1
  22. data/lib/textus/init/templates/machine_intake.rb +1 -1
  23. data/lib/textus/init.rb +4 -4
  24. data/lib/textus/jobs/handlers.rb +62 -0
  25. data/lib/textus/jobs/scheduler.rb +36 -0
  26. data/lib/textus/jobs/seeder.rb +57 -0
  27. data/lib/textus/layout.rb +8 -0
  28. data/lib/textus/maintenance/drain.rb +42 -0
  29. data/lib/textus/maintenance/retention/apply.rb +52 -0
  30. data/lib/textus/maintenance/serve.rb +30 -0
  31. data/lib/textus/maintenance/worker.rb +74 -0
  32. data/lib/textus/manifest/capabilities.rb +1 -1
  33. data/lib/textus/manifest/data.rb +16 -1
  34. data/lib/textus/manifest/schema/keys.rb +1 -1
  35. data/lib/textus/manifest/schema/validator.rb +3 -3
  36. data/lib/textus/manifest/schema/vocabulary.rb +2 -2
  37. data/lib/textus/mcp/server.rb +1 -1
  38. data/lib/textus/ports/build_lock.rb +1 -1
  39. data/lib/textus/ports/produce_on_write_subscriber.rb +28 -24
  40. data/lib/textus/ports/queue.rb +130 -0
  41. data/lib/textus/produce/acquire/handler.rb +1 -1
  42. data/lib/textus/produce/acquire/intake.rb +3 -3
  43. data/lib/textus/produce/engine.rb +10 -58
  44. data/lib/textus/produce/events.rb +1 -1
  45. data/lib/textus/read/freshness.rb +2 -2
  46. data/lib/textus/read/get.rb +3 -3
  47. data/lib/textus/read/jobs.rb +31 -0
  48. data/lib/textus/role.rb +1 -1
  49. data/lib/textus/version.rb +1 -1
  50. data/lib/textus/write/enqueue.rb +50 -0
  51. metadata +14 -2
  52. data/lib/textus/maintenance/reconcile.rb +0 -160
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5832639952f2e74862490a982a1bed11beb0d8b46febb18d3db60ab03a7c533e
4
- data.tar.gz: e959c0f82d1b7b2b50161f1089d3851cdf2f0452147efd80563f8c5a60299d29
3
+ metadata.gz: 2095b69b4e135e71a1ae786759d299c8df4e78a0164a142411d0df72ce63aa4e
4
+ data.tar.gz: fb01669c8458fbfa0db50cff6314d30c9033abc02632998e431b2d11cb7e893c
5
5
  SHA512:
6
- metadata.gz: cdddc5128eddb2790a4a449bd53a981f18835f85fe19026a036bb95c17d9ac71bd430112e911f8c014a31cfec0a11e40874eff4fc5597353a1ed1a0844138d4b
7
- data.tar.gz: 454407cf83ac596d39a78bd31dc4233289153e98a82dce46fdc34ec77dfa281a3637e92a7b00f5a83149d945c6a4a6bc1be59fdafe606344d530da04c13130c1
6
+ metadata.gz: 728c7e305b878cb68bf10af0bd93d9ca93b8b1a2be07a7ec7333de73f127415d4aeae112863bc9fa4d1cec67f51a0da25e2ad984b7b4283f4724c878241ff0c0
7
+ data.tar.gz: 7019ce011bf7297d430c2de9f8da591bc951d863e6834ba40c928eeca1bb0082626e2a0814c8bb6412288fb60659faedc5807e77258fbbcf3b3099672ddb19f4
data/CHANGELOG.md CHANGED
@@ -9,6 +9,18 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.52.0 — 2026-06-09 — The authority model is a produced reference doc (ADR 0112)
13
+
14
+ The "who may write what" tables stop being hand-copied across the canon docs and become a fourth generated reference doc, projected from the source of truth on every `drain`.
15
+
16
+ ### Added
17
+
18
+ - **`docs/reference/authority.md` is a produced reference doc** (ADR 0112). A new `authority` registry handler projects the `artifacts.derived.authority` entry — the zone-kind↔capability bijection (from `Schema::Vocabulary::LANES`), this manifest's zones, and its roles with the zone-kinds each can write — rendered through `authority.mustache`. It joins `verbs.md` / `schema.md` / `adr-log.md` on the ADR 0097/0102 produced-docs pattern; `GeneratorDrift` + `HandlerAllowlist` guard it (no new doctor check).
19
+
20
+ ### Changed
21
+
22
+ - **The authority model's SSoT splits along the produce/guard seam** (ADR 0112, refining ADR 0098). The *current-values* tables move to the generated `authority.md`; `reference/zones.md` drops its three hand-maintained tables, keeps what each capability *means* as prose, and links to the generated values. `explanation/concepts.md` and the docs index re-point accordingly; the orientation template now names four generated reference docs.
23
+
12
24
  ## 0.51.0 — 2026-06-08 — The reconcile era: one engine for produce + sweep, `source`/`retention`, produced reference docs (ADR 0087, 0090–0095, 0097)
13
25
 
14
26
  `build` folds into `reconcile`, materialization becomes system-pushed, the producer kinds and machine zones collapse onto one `source` + `retention` grammar, and the machine-derivable reference docs graduate to produced artifacts.
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- Generated from .textus/zones/knowledge/readme.md — edit there, then run `textus reconcile`. Do not hand-edit README.md (it is clobbered on reconcile and flagged by doctor). ADR 0103. -->
1
+ <!-- Generated from .textus/zones/knowledge/readme.md — edit there, then run `textus drain`. Do not hand-edit README.md (it is clobbered on drain and flagged by doctor). ADR 0103. -->
2
2
  <p align="center">
3
3
  <picture>
4
4
  <source media="(prefers-color-scheme: dark)" srcset="assets/branding/wordmark-dark.png">
@@ -38,7 +38,7 @@ flowchart LR
38
38
  human -->|author| knowledge["knowledge<br/>(canon)"]
39
39
  agent -->|keep| notebook["notebook<br/>(workspace)"]
40
40
  agent -->|propose| proposals["proposals<br/>(queue)"]
41
- automation -->|reconcile| artifacts["artifacts<br/>(machine)"]
41
+ automation -->|drain| artifacts["artifacts<br/>(machine)"]
42
42
 
43
43
  proposals ==>|human accept| knowledge
44
44
  knowledge -.->|projection source| artifacts
@@ -79,7 +79,7 @@ Without coordination, they overwrite each other and nothing remembers why. textu
79
79
  knowledge/ author only — who you are, what you decide, how you sound (knowledge.identity.* for identity facts)
80
80
  notebook/ keep only — agent's own durable lane (agents keep theirs; bytes climb to knowledge only via propose→accept)
81
81
  proposals/ propose (agent + human) — proposals waiting on a human accept
82
- artifacts/ reconcile only — machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
82
+ artifacts/ converge only — machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
83
83
  ```
84
84
 
85
85
  An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `notebook/` (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
@@ -109,7 +109,7 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
109
109
 
110
110
  ## Try it
111
111
 
112
- - **Worked end-to-end store** — the role gate (propose → accept), reconcile/publish (`CLAUDE.md` / `AGENTS.md` generated from knowledge entries), schemas, templates, and a hook: [`examples/project/`](examples/project/)
112
+ - **Worked end-to-end store** — the role gate (propose → accept), drain/publish (`CLAUDE.md` / `AGENTS.md` generated from knowledge entries), schemas, templates, and a hook: [`examples/project/`](examples/project/)
113
113
  - **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md)
114
114
 
115
115
  ## Protocol, not just a gem
@@ -143,13 +143,13 @@ You get `.textus/` with all four zone directories, baseline schemas, a starter m
143
143
  roles:
144
144
  - { name: human, can: [author, propose] }
145
145
  - { name: agent, can: [propose, keep] }
146
- - { name: automation, can: [reconcile] }
146
+ - { name: automation, can: [converge] }
147
147
 
148
148
  zones:
149
149
  - { name: knowledge, kind: canon } # author — canonical truth
150
150
  - { name: notebook, kind: workspace } # keep — agent's own durable lane
151
151
  - { name: proposals, kind: queue } # propose — proposals awaiting accept
152
- - { name: artifacts, kind: machine } # reconcile — external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
152
+ - { name: artifacts, kind: machine } # converge — external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
153
153
  ```
154
154
 
155
155
  ```
@@ -168,7 +168,7 @@ zones:
168
168
  audit/audit.log # append-only NDJSON event ledger, every write (rotates at ~50 MB)
169
169
  state/cursor.<role> # per-role pulse cursor — where `pulse --since` resumes
170
170
  locks/ # per-key produce locks + the produce mutex
171
- sentinels/ # publish bookkeeping (target sha) — regenerated on reconcile (ADR 0070)
171
+ sentinels/ # publish bookkeeping (target sha) — regenerated on drain (ADR 0070)
172
172
  ```
173
173
 
174
174
  Manifest `path:` fields are relative to `.textus/zones/`. So `knowledge.notes.org.jane` lives at `.textus/zones/knowledge/notes/org/jane.md`.
@@ -180,20 +180,20 @@ textus get knowledge.notes.org.jane
180
180
  textus list --zone=knowledge
181
181
  printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
182
182
  | textus put knowledge.notes.bob --as=human --stdin
183
- textus reconcile --as=automation # re-pull stale inputs + recompute derived outputs
183
+ textus drain --as=automation # re-pull stale inputs + recompute derived outputs
184
184
  textus rule list # show every rule block
185
185
  textus audit --limit=20 # query the audit log
186
186
  ```
187
187
 
188
188
  (All verbs return JSON envelopes; `--output=json` is the default and the only format in v1.)
189
189
 
190
- For a worked store — knowledge entries, a staged proposal, schemas, a template, and a `reconcile` that publishes `CLAUDE.md` / `AGENTS.md` — see [`examples/project/`](examples/project/).
190
+ For a worked store — knowledge entries, a staged proposal, schemas, a template, and a `drain` that publishes `CLAUDE.md` / `AGENTS.md` — see [`examples/project/`](examples/project/).
191
191
 
192
192
  ## What's shipped
193
193
 
194
194
  - **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
195
195
  - **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
196
- - **Capability × zone-kind gate.** Writes carry `--as=<role>`; a role may write a zone iff it holds the capability the zone's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`reconcile`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
196
+ - **Capability × zone-kind gate.** Writes carry `--as=<role>`; a role may write a zone iff it holds the capability the zone's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
197
197
  - **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, stale keys, pending proposals). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
198
198
  - **`textus doctor`.** Health checks across schemas, hooks, keys, sentinels, and the audit log.
199
199
 
@@ -208,13 +208,13 @@ Every command operates on one store, located in this order: `--root <path>` flag
208
208
 
209
209
  ## Produce and publish
210
210
 
211
- Produced entries (`kind: produced`) declare how they're acquired in one `source:` block (ADR 0093/0094); `reconcile` materialises them:
211
+ Produced entries (`kind: produced`) declare how they're acquired in one `source:` block (ADR 0093/0094); `drain` materialises them:
212
212
 
213
213
  - **`source: { from: project, select: [...], pluck:, sort_by:, limit:, transform: name }`** — a *projection*: textus computes the entry's data from other entries, then renders it through a template under `.textus/templates/` (markdown/text) or a templateless path that lets a transform hook shape the output directly (json/yaml). Projections cap at 1000 rows; the vendored Mustache subset caps at depth 8. No partials, no lambdas, no HTML escaping.
214
- - **`source: { from: handler, handler: name, ttl: 1h, config: {...} }`** — *intake*: an RPC handler pulls external bytes on a `ttl` cadence; `reconcile` re-pulls when the entry goes stale.
214
+ - **`source: { from: handler, handler: name, ttl: 1h, config: {...} }`** — *intake*: an RPC handler pulls external bytes on a `ttl` cadence; `drain` re-pulls when the entry goes stale.
215
215
  - **`source: { from: command, sources: [...] }`** — *externally generated*: an out-of-band command writes the file; textus tracks the declared `sources` for staleness.
216
216
 
217
- Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single produced file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/.run/sentinels/` (git-ignored runtime state, regenerated on reconcile — ADR 0070). See SPEC §5.2, §5.3, §5.12.
217
+ Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single produced file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/.run/sentinels/` (git-ignored runtime state, regenerated on drain — ADR 0070). See SPEC §5.2, §5.3, §5.12.
218
218
 
219
219
  ## Extension points
220
220
 
@@ -237,7 +237,7 @@ textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectorie
237
237
  | `:entry_produced` | a produced entry materializes |
238
238
  | `:entry_published` | a produced file is copied to its target |
239
239
  | `:proposal_accepted` · `:proposal_rejected` | a proposal is resolved |
240
- | `:entry_fetch_started` · `:entry_fetch_failed` · `:produce_failed` · `:reconcile_failed` | produce lifecycle |
240
+ | `:entry_fetch_started` · `:entry_fetch_failed` · `:produce_failed` | produce lifecycle |
241
241
  | `:store_loaded` · `:session_opened` | the store loads · a role connects |
242
242
 
243
243
  ```ruby
@@ -261,13 +261,13 @@ Textus.hook do |reg|
261
261
  end
262
262
  ```
263
263
 
264
- Stale intake entries are re-pulled by `reconcile`, not by reads — `get` is a pure
264
+ Stale intake entries are re-pulled by `drain`, not by reads — `get` is a pure
265
265
  read that annotates the returned envelope with a freshness verdict (ADR 0089).
266
- `reconcile` re-pulls anything past its `source.ttl` and recomputes derived outputs:
266
+ `drain` re-pulls anything past its `source.ttl` and recomputes derived outputs:
267
267
 
268
268
  ```sh
269
- textus reconcile --as=automation # re-pull every stale intake + recompute derived
270
- textus reconcile artifacts.feeds --as=automation # scope to one prefix
269
+ textus drain --as=automation # re-pull every stale intake + recompute derived
270
+ textus drain artifacts.feeds --as=automation # scope to one prefix
271
271
  textus get artifacts.feeds.calendar.events # a pure read; carries a freshness verdict
272
272
  ```
273
273
 
@@ -279,7 +279,7 @@ See [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md) for the agent boot
279
279
 
280
280
  ## Examples
281
281
 
282
- [`examples/project/`](examples/project/) — textus as a project's own context store (a fictional Rails service, `ledger`). Human-authored `knowledge/` (project facts, runbooks), a staged ADR in `proposals/` showing the agent-propose / human-accept loop, schemas validating each family, a mustache template plus a `:transform_rows` hook, and a `reconcile` that publishes the `artifacts.derived.orientation` projection to `CLAUDE.md` and `AGENTS.md`. Includes a copy-paste adoption recipe for your own repo.
282
+ [`examples/project/`](examples/project/) — textus as a project's own context store (a fictional Rails service, `ledger`). Human-authored `knowledge/` (project facts, runbooks), a staged ADR in `proposals/` showing the agent-propose / human-accept loop, schemas validating each family, a mustache template plus a `:transform_rows` hook, and a `drain` that publishes the `artifacts.derived.orientation` projection to `CLAUDE.md` and `AGENTS.md`. Includes a copy-paste adoption recipe for your own repo.
283
283
 
284
284
  ## Tests
285
285
 
data/SPEC.md CHANGED
@@ -76,7 +76,7 @@ implementation is the bug.
76
76
 
77
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. Produced entries acquire their data via a declared `source:` (a pure projection over other entries, an external fetch, or an out-of-band command); that data is then optionally published to repo-relative paths — copied verbatim, or rendered through a per-target Mustache template. The CLI surface (`textus get/put/list/where/schema/reconcile/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
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,10 +84,10 @@ 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`, `reconcile`).
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`, `reconcile`, …).
90
+ - **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `drain`, `serve`, …).
91
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
 
@@ -98,7 +98,7 @@ 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 `artifacts` machine zone in the default scaffold, under the `artifacts.feeds.*` keys; any `machine` zone, writable by a role with `reconcile`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
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
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
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. |
@@ -140,14 +140,14 @@ The root is `.textus/` at the project working directory. A typical tree:
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
142
  proposals/ # zone: proposals (kind: queue — propose-holders write)
143
- artifacts/ # zone: artifacts (kind: machine — reconcile-holders write; external inputs artifacts.feeds.* + computed outputs artifacts.derived.*)
143
+ artifacts/ # zone: artifacts (kind: machine — converge-holders write; external inputs artifacts.feeds.* + computed outputs artifacts.derived.*)
144
144
  ```
145
145
 
146
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.
147
147
 
148
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.
149
149
 
150
- `.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `key_delete`, `accept`, and `reconcile`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
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).
151
151
 
152
152
  ### 3.1 Store location precedence
153
153
 
@@ -170,7 +170,7 @@ version: textus/3
170
170
  roles:
171
171
  - { name: human, can: [author, propose] }
172
172
  - { name: agent, can: [propose] }
173
- - { name: automation, can: [reconcile] }
173
+ - { name: automation, can: [converge] }
174
174
 
175
175
  zones:
176
176
  - name: knowledge
@@ -201,7 +201,7 @@ entries:
201
201
  path: artifacts/catalogs/people.md
202
202
  zone: artifacts
203
203
  schema: null
204
- owner: automation:reconcile
204
+ owner: automation:converge
205
205
 
206
206
  rules:
207
207
  - match: artifacts.feeds.**
@@ -229,7 +229,7 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
229
229
 
230
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.
231
231
 
232
- **Subtree mirror (a `{ tree: }` target).** A nested manifest entry MAY include a `{ tree: "dir" }` target to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). It is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every reconcile the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). When a `{ tree: }` target directory overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
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.
233
233
 
234
234
  ```yaml
235
235
  - key: working.skills
@@ -242,7 +242,7 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
242
242
  ignore: ["*.tmp", ".DS_Store"]
243
243
  ```
244
244
 
245
- **`inject_boot:` (a publish-target flag).** A to-target with a `template:` MAY declare `inject_boot: true`. When `textus reconcile` publishes that target, it merges the `textus boot` envelope (§9) into the render data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside the entry's data. `inject_boot:` is per-target and only meaningful alongside a `template:`; on a templateless or tree target it is rejected at load — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
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.
246
246
 
247
247
  **Lookup rule:** to resolve a key, find the entry with the longest `key:` prefix that matches. If that entry has `nested: true`, the remaining segments map to subdirectories under its `path`. Otherwise the key must equal an entry exactly. The resolved filesystem path is `<.textus root>/zones/<entry.path>[/<remaining>...].md` — implementations MUST prepend `zones/` to the manifest `path:` when constructing the filesystem location.
248
248
 
@@ -256,21 +256,21 @@ The kind→verb mapping is closed:
256
256
  |---|---|---|
257
257
  | `canon` | `author` | Authored truth — only the trust anchor writes directly. |
258
258
  | `workspace` | `keep` | Agent's own durable lane — bytes never auto-promote; climb to `canon` only via propose→accept. |
259
- | `machine` | `reconcile` | Machine-maintained: external bytes pending validation + outputs computed from other zones. |
259
+ | `machine` | `converge` | Machine-maintained: external bytes pending validation + outputs computed from other zones. |
260
260
  | `queue` | `propose` | Proposals awaiting promotion. |
261
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 `reconcile`, because machine-maintained bytes (external inputs and computed outputs alike) are kept current by the same `reconcile` sweep.
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`).
263
263
 
264
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.
265
265
 
266
- Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[reconcile]`):
266
+ Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[converge]`):
267
267
 
268
268
  | Zone | `kind` | Required capability | Writable by (default) | Use case |
269
269
  |---|---|---|---|---|
270
270
  | `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network. `knowledge.identity.*` is the identity key convention. |
271
271
  | `notebook` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
272
272
  | `proposals` | `queue` | `propose` | `agent`, `human` | Proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `knowledge`. |
273
- | `artifacts` | `machine` | `reconcile` | `automation` | Machine-maintained, never by humans or agents directly: declared external inputs (calendar, feeds, scraped pages) under `artifacts.feeds.*` pulled in by the `reconcile` sweep, and computed outputs (catalogs, indexes, published context) under `artifacts.derived.*` materialized via `textus reconcile`. |
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`. |
274
274
 
275
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).
276
276
 
@@ -281,7 +281,7 @@ durable lane), `machine` (machine-maintained: external bytes pending validation
281
281
  manifest MUST declare at most one `queue` zone and at most one `machine` zone.
282
282
  Because authority is derived, a manifest is rejected at load if it declares a
283
283
  zone whose required verb is held by **no** declared role (`machine` ⇒ a role with
284
- `reconcile`, `queue` ⇒ `propose`, `workspace` ⇒ `keep`, `canon` ⇒ `author`).
284
+ `converge`, `queue` ⇒ `propose`, `workspace` ⇒ `keep`, `canon` ⇒ `author`).
285
285
  Coordination is keyed off the declared kind: a zone is machine-maintained only if
286
286
  it declares `kind: machine`, and proposals route to the declared `queue` zone —
287
287
  there is no name-based fallback. A manifest with a kind-less zone is rejected at
@@ -302,7 +302,7 @@ The effective role for any CLI invocation is resolved in this order; the first m
302
302
  |---|---|---|
303
303
  | `human` | `[author, propose]` | Interactive user at a terminal; the single trust anchor. |
304
304
  | `agent` | `[propose]` | Long-running AI or LLM process; stages proposals. |
305
- | `automation` | `[reconcile]` | Scheduled or one-shot scripts: keep the `machine` lane current — pull external sources in and materialize computed outputs. |
305
+ | `automation` | `[converge]` | Scheduled or one-shot scripts: keep the `machine` lane current — pull external sources in and materialize computed outputs. |
306
306
 
307
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`.
308
308
 
@@ -318,11 +318,11 @@ it holds via `can:`:
318
318
  roles:
319
319
  - { name: owner, can: [author, propose] }
320
320
  - { name: proposer, can: [propose] }
321
- - { name: machine, can: [reconcile] }
321
+ - { name: machine, can: [converge] }
322
322
  - { name: keeper, can: [keep] }
323
323
  ```
324
324
 
325
- Capability allow-list: `propose`, `author`, `keep`, `reconcile`. The mapping from
325
+ Capability allow-list: `propose`, `author`, `keep`, `converge`. The mapping from
326
326
  zone-kind to its required capability is a **bijection** (ADR 0091, which folded
327
327
  the former `quarantine` + `derived` kinds back into one `machine` kind — undoing
328
328
  the two-kind split of ADR 0090): each capability authorizes exactly one
@@ -333,11 +333,11 @@ zone-kind:
333
333
  | `author` | `canon` |
334
334
  | `keep` | `workspace` |
335
335
  | `propose` | `queue` |
336
- | `reconcile` | `machine` |
336
+ | `converge` | `machine` |
337
337
 
338
338
  A manifest naming a folded capability — `ingest` or `build`, or the pre-0088
339
339
  spelling `fetch` — in a `can:` list is rejected at load with a hint pointing to
340
- `reconcile` (ADR 0090, 0091).
340
+ `converge` (ADR 0090, 0091, 0111).
341
341
 
342
342
  `author` is the single **trust anchor**: **at most one role may hold `author`**
343
343
  (a manifest declaring two or more is rejected at load). The `accept` and
@@ -352,7 +352,7 @@ When the `roles:` block is omitted, the default mapping applies:
352
352
  |---|---|
353
353
  | `human` | `[author, propose]` |
354
354
  | `agent` | `[propose, keep]` |
355
- | `automation` | `[reconcile]` |
355
+ | `automation` | `[converge]` |
356
356
 
357
357
  Wire protocol `textus/3` is unchanged — capabilities are a manifest/semantics
358
358
  concept and never appear on the wire.
@@ -366,7 +366,7 @@ when the acting role holds `author`). See §5.11 for composing extra predicates
366
366
 
367
367
  ### 5.2 Source layer (produced entries)
368
368
 
369
- Produced entries live in a `machine` zone (writable by a role holding `reconcile`; `automation` by default) — `artifacts` in the default scaffold. They are not authored by hand; their **data** is acquired from a declared `source:` block with a `from:` discriminator (`project | handler | command`). A `source:` is **acquire-only**: it produces the data the store holds; it does **not** render. Rendering is a publish concern (§5.3). Every produced entry is `kind: produced` (ADR 0095); the **produce-method** is read from `source.from` — `from: project | command` is *derived* (internal projection / out-of-band command), `from: handler` is *intake* (external fetch, §5.4). `kind:` no longer restates the produce-method (the former `kind: derived` / `kind: intake` are rejected at load with a fold hint).
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).
370
370
 
371
371
  #### 5.2.1 Projection source (`from: project`)
372
372
 
@@ -464,15 +464,15 @@ A **to-target** carries `to:` (required) and optionally `template:` / `inject_bo
464
464
 
465
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
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 reconcile (via content-identical adoption) rather than committed. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
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.
468
468
 
469
- **Subtree mirror.** A nested entry MAY include a `{ tree: "dir" }` target (see §4). On every reconcile, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/.run/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The reconcile envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `produced` array, plus a `pruned` array listing any orphaned managed files removed on this pass. Targets that would resolve outside the repo root are refused. When a `{ tree: }` target overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
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).
470
470
 
471
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.
472
472
 
473
473
  ### 5.4 Intake source (`from: handler`)
474
474
 
475
- Intake entries acquire their data via `source: { from: handler, ... }` — an external fetch through a registered handler. The `source:` block fully replaces the former `intake:` block; the entry's `kind:` is `produced` and the *intake* produce-method is read from `source.from: handler` (ADR 0095). Like every `source:`, it is acquire-only — a fetched feed is **data**, and if it needs rendering for a consumer that is a publish target's `template:` (§5.3), never the handler's job. textus itself makes no implicit network calls: the handler runs only when `textus reconcile` (the scheduled sweep) or a `hook run` event re-pulls a stale entry past its `source.ttl` — a `get` never runs it (ADR 0089).
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).
476
476
 
477
477
  ```yaml
478
478
  - key: feeds.calendar.events
@@ -483,10 +483,10 @@ Intake entries acquire their data via `source: { from: handler, ... }` — an ex
483
483
  handler: ical-events
484
484
  config:
485
485
  url: "https://calendar.google.com/.../basic.ics"
486
- ttl: 6h # re-pull cadence; reconcile re-pulls when past ttl
486
+ ttl: 6h # re-pull cadence; drain/serve re-pulls when past ttl
487
487
  ```
488
488
 
489
- `handler` names a registered `:resolve_handler` hook (see §5.10); `config` is an opaque hash handed to the handler. `ttl` is the re-pull cadence: the `reconcile` sweep (and `hook run`) re-pulls the entry when `now - last_fetched_at > ttl`. A `get` annotates the entry with `stale: true` when past ttl but **never** re-pulls (ADR 0089). Age-based garbage collection of intake entries is separate and orthogonal — declare a `retention:` rule block (§5.11).
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).
490
490
 
491
491
  > **Note:** `list`/`where` paths do **not** annotate freshness — only `get` does. None of them ever re-pull.
492
492
 
@@ -500,7 +500,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
500
500
 
501
501
  **Re-pull paths.** Ingest is system-pushed (ADR 0089) — never triggered by a read:
502
502
 
503
- 1. **Scheduled sweep** — `textus reconcile --as=automation` re-pulls every intake entry past its `source.ttl`: it resolves the entry's `source.handler`, invokes the registered `:resolve_handler` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `reconcile` (`automation` by default). Run it on a cron/timer.
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
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.
505
505
 
506
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`.)
@@ -661,7 +661,7 @@ end
661
661
  | `:entry_fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
662
662
  | `:entry_fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
663
663
 
664
- The two `:entry_fetch_*` lifecycle events report the progress and failures of intake fetches during `reconcile` / `hook run`.
664
+ The two `:entry_fetch_*` lifecycle events report the progress and failures of intake fetches during `drain`/`serve` / `hook run`.
665
665
 
666
666
  **`:entry_fetch_started`** fires immediately before an intake handler is invoked. `mode:` is `"refresh"`.
667
667
 
@@ -703,9 +703,9 @@ rules:
703
703
 
704
704
  | Slot | Type | Meaning |
705
705
  |---|---|---|
706
- | `retention` | `{ ttl, action: drop\|archive }` | Age-based garbage collection (ADR 0093). `action` is `drop` (delete the entry) or `archive` (copy to `<store>/archive/<relative-path>` then delete). Age is measured from `_meta.last_fetched_at` (intake entries) when present, else the leaf file's modification time. **Destructive — applied only on the `reconcile` sweep (Phase 2), never on a write or read.** Orthogonal to production: an intake entry may declare both `source: { ..., ttl: 1h }` (re-pull cadence) and a `retention: { ttl: 90d, action: archive }` rule. `retention:` on a `derived` entry is rejected at load. |
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
707
  | `intake_handler_allowlist` | list of strings | Constrains which `source.handler:` names may be used by intake entries matched by this block. Enforced by `textus doctor`. |
708
- | `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `key_delete`, `key_mv`, `accept`, `reject`, `reconcile`). Predicate names are drawn from the closed vocabulary (`zone_writable_by`, `schema_valid`, `author_held`, `target_is_canon`, `etag_match`, `fresh_within`); parameterized predicates use `{ name: param }` form, e.g. `{ fresh_within: "1h" }`. Enforced — the transition refuses (`guard_failed`) if any predicate fails; the topology refusal keeps the `write_forbidden` code. |
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. |
709
709
 
710
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.
711
711
 
@@ -875,7 +875,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
875
875
  | `put K --stdin --as=R` | write (stores the stdin JSON; runs no handler — ADR 0089) | per zone |
876
876
  | `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
877
877
  | `key delete K --if-etag=E --as=R` | write | per zone |
878
- | `reconcile [--prefix=K] [--zone=Z] [--dry-run]` | write | `reconcile`-holder (typically `automation`) |
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 |
879
881
  | `accept K --as=human` | write | `author`-holder (typically `human`) |
880
882
  | `reject K --as=human` | write | `author`-holder (typically `human`) |
881
883
  | `init` | write | `human` |
@@ -899,7 +901,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
899
901
 
900
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).
901
903
 
902
- The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `reconcile` is also on MCP (ADR 0076, ADR 0087): it is caller-agnostic and self-elevatingits materialize phase always runs as the manifest's `reconcile`-capable actor regardless of the calling role, grants no authority over content (materialization is a pure, idempotent function of already-accepted canon, ADR 0070), and the whole two-phase pass is serialized by a shared single-writer maintenance lock across all transports so a concurrent CLI, reactive, or background pass cannot collide with an MCP-triggered one.
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).
903
905
 
904
906
  `latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
905
907
 
@@ -930,11 +932,11 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
930
932
 
931
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).
932
934
 
933
- The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) against each intake entry's `source.ttl`. ADR 0085 removed the standalone `freshness` verb that used to render these rows; the scan is now Ruby-only (consumed by `pulse` and the hook context), and human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `source.ttl` and retention policy). `textus reconcile` produces all in-scope derived entries and re-pulls stale intake entries in Phase 1, then runs the destructive `retention:` sweep in Phase 2 (§5.11); `--dry-run` prints the plan (`would_produce` plus `would_drop`/`would_archive`) without executing.
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`.
934
936
 
935
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`.
936
938
 
937
- `textus reconcile [--prefix=K] [--zone=Z] [--dry-run]` is the manual full maintenance pass (ADR 0093). It runs two phases under **one** shared maintenance lock: **Phase 1 (non-destructive)** produce ALL in-scope derived entries' data (always rebuild — pure/idempotent, unchanged sources write nothing) and re-pull every intake entry past its `source.ttl`; nested entries with a `{ tree: }` publish target are also produced. **Phase 2 (destructive)** — the `retention:` sweep: drop or archive entries past their `retention.ttl` (§5.11). Phase 1 self-elevates to the manifest's `reconcile`-capable actor (`automation` by default) — materialization is a pure function of already-accepted canon and grants no authority over content. Phase 2 runs as the **caller** (gated as the caller's own `key_delete` authority), never self-elevates. `--dry-run` returns a plan without mutating: `would_produce` (the keys Phase 1 would write) alongside `would_drop`/`would_archive` from Phase 2. An apply returns `produced`, `produce_failed`, `dropped`, `archived`, `failed`, and `health`. In day-to-day use derived entries stay fresh **reactively** — a canon write re-materializes dependent derived entries inline (the per-write reactive rebuild is "reconcile narrowed to rdeps ∩ derived") — so `reconcile` is the on-demand catch-all, not a step in the normal write loop.
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.
938
940
 
939
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.
940
942
 
@@ -984,16 +986,16 @@ Given the `person` schema and a `put` whose frontmatter omits `relationship`, th
984
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.
985
987
 
986
988
  **Fixture E — Projection produce:**
987
- Given a manifest entry `artifacts.derived.skills` with `kind: produced` and `source: { from: project, select: knowledge.projects, ... }` (the derived produce-method read from `source.from`), `textus reconcile --prefix=artifacts.derived.skills` produces the derived entry's **data** on disk (serialized per `format:`) matching the projected shape. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so re-running with unchanged sources reproduces it byte-for-byte and writes nothing.
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.
988
990
 
989
991
  **Fixture F — Mustache render at publish:**
990
- Given a produced entry with a to-target `{ to:, template: <name> }`, `textus reconcile` renders the entry's stored data through the template and emits a file whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization). Two to-targets with different templates produce different bytes from the one entry.
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.
991
993
 
992
994
  **Fixture G — Copy publish:**
993
- Given a manifest entry with a templateless to-target `publish: [{ to: <path> }]`, a successful `textus reconcile` for that entry leaves a plain file at `<path>` whose contents are the entry's content re-serialized without `_meta` (byte-identical to a clean consumer config), accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `reconcile` is idempotent.
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.
994
996
 
995
997
  **Fixture H — Audit log format:**
996
- Every successful write verb (`put`, `key_delete`, `reconcile`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
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.
997
999
 
998
1000
  **Fixture I — Pending → accept:**
999
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.
@@ -57,7 +57,7 @@ read/{get,list,where,uid,schema_envelope,
57
57
  deps,rdeps,published,validate_all,boot,doctor,
58
58
  freshness,audit,blame,rule_explain,rule_list,pulse}.rb
59
59
  write/{put,key_delete,key_mv,accept,reject,propose}.rb
60
- maintenance/{reconcile,key_mv_prefix,key_delete_prefix,
60
+ maintenance/{drain,serve,worker,key_mv_prefix,key_delete_prefix,
61
61
  zone_mv,rule_lint}.rb
62
62
  produce/{engine,events,render,
63
63
  acquire/{intake,handler,projection,serializer}}.rb
@@ -85,7 +85,7 @@ Storage::FileStore (bytes-only port: read/write/delete/
85
85
  Manifest (Data, Resolver, Policy, Rules)
86
86
  Schemas (eager-load cache)
87
87
  Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
88
- BuildLock,ProduceOnWriteSubscriber,SentinelStore}
88
+ BuildLock,Queue,ProduceOnWriteSubscriber,SentinelStore}
89
89
  Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
90
90
  Signature,Builtin,ErrorLog}
91
91
  Entry::{Markdown,Json,Yaml,Text} (format strategies)
@@ -123,7 +123,7 @@ reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
123
123
 
124
124
  Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
125
125
 
126
- - `Produce::Engine` — runs `reconcile`'s produce pipeline; composes `Acquire::Intake` (external pull via handler) with `Produce::Render` (template-driven publish) per entry. `AsyncRunner` (nested in `Engine`) enqueues reactive re-produce on `entry_written` events and drains before the process exits.
126
+ - `Produce::Engine` — runs the produce pipeline that `drain`/`serve` invoke via the `materialize` job handler; composes `Acquire::Intake` (external pull via handler) with `Produce::Render` (template-driven publish) per entry. Reactive re-produce is enqueued as `materialize` jobs by `Ports::ProduceOnWriteSubscriber` and run by a worker (no in-process thread runner).
127
127
  - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
128
128
 
129
129
  ## Container
@@ -150,7 +150,7 @@ Ports are infrastructure adapters with an interface defined by the domain. Each
150
150
  | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
151
151
  | `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel so the next publish can confirm the target is managed. |
152
152
  | `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline. Raises `BuildInProgress` if a build is already running. |
153
- | `Ports::ProduceOnWriteSubscriber` | Pub-sub listener on `entry_written`; enqueues keys into `Produce::Engine::AsyncRunner` for reactive re-produce after any write. |
153
+ | `Ports::ProduceOnWriteSubscriber` | Pub-sub listener on `entry_written`/`entry_deleted`/`entry_renamed`; enqueues `materialize` jobs onto `Ports::Queue` for reactive re-produce after any write/delete/rename. |
154
154
  | `Ports::SentinelStore` | Reads and writes the per-target sentinel file that `Publisher` uses to detect unmanaged overwrites. |
155
155
 
156
156
  Application use cases access ports only through `Container` fields — never through the raw `Store`.
@@ -186,11 +186,11 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
186
186
 
187
187
  ## Read path (`store.get(key)`)
188
188
 
189
- `Read::Get` is the single public read verb. It is a **pure read** (ADR 0089): it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict — it NEVER ingests and NEVER mutates. The read-through that once refreshed a stale entry in-process (ADR 0062) is removed; quarantine freshness is system-pushed via `reconcile` (scheduled sweep) and `hook run` (event push).
189
+ `Read::Get` is the single public read verb. It is a **pure read** (ADR 0089): it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict — it NEVER ingests and NEVER mutates. The read-through that once refreshed a stale entry in-process (ADR 0062) is removed; quarantine freshness is system-pushed via `drain` (scheduled sweep) and `hook run` (event push).
190
190
 
191
191
  1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
192
192
  2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The verb takes only `key` — there is no `fetch` flag on any surface.
193
- 3. `Read::Get#call(key)` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no `upkeep` rule, the envelope is annotated fresh. A stale entry with `upkeep: { ttl:, action: refresh }` is returned **stale** — the read does not refresh it; the next `reconcile` does.
193
+ 3. `Read::Get#call(key)` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no `upkeep` rule, the envelope is annotated fresh. A stale entry with `upkeep: { ttl:, action: refresh }` is returned **stale** — the read does not refresh it; the next `drain` does.
194
194
 
195
195
  Because the read is always pure, every caller — interactive reads, dashboards, and the direct in-process callers (accept/reject/publish, materializer, uid, validate_all/validator, schema/tools, hooks/context) — gets the same orchestrator-free, side-effect-free read. The prior read-through path (`get_or_fetch`, then the `fetch:`-flagged `Read::Get`, ADR 0062) and its `Write::FetchOrchestrator` are gone (ADR 0089).
196
196
 
@@ -205,11 +205,11 @@ Because the read is always pure, every caller — interactive reads, dashboards,
205
205
 
206
206
  `Write::KeyMv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
207
207
 
208
- ## Produce path (`reconcile` + reactive `entry_written`)
208
+ ## Produce path (`drain`/`serve` + reactive `entry_written`)
209
209
 
210
210
  The produce pipeline handles two concerns — **acquire** (pull live data via an intake handler) and **render** (template-driven artifact publish) — unified under `Produce::Engine`.
211
211
 
212
- `Produce::Engine.converge(container:, call:, keys:)` is the entry point for both `reconcile` (scheduled batch) and the reactive `AsyncRunner` (triggered on `entry_written` by `Ports::ProduceOnWriteSubscriber`).
212
+ `Produce::Engine.converge(container:, call:, keys:)` is the entry point the `materialize` job handler calls. Both the batch path (`drain`/`serve` seed jobs) and the reactive path (`Ports::ProduceOnWriteSubscriber` enqueues `materialize` jobs on `entry_written`/`entry_deleted`/`entry_renamed`) flow through the queue worker into `converge`.
213
213
 
214
214
  For each key, `Engine#produce_one`:
215
215
 
@@ -222,7 +222,7 @@ For each key, `Engine#produce_one`:
222
222
  - `Acquire::Handler` resolves and invokes the RPC callable under the timeout deadline. (The sibling **projection** sub-path — `from: project` entries — instead runs `Acquire::Projection`, which renders data files through `Acquire::Serializer::{Json,Yaml,Text}` before persisting.)
223
223
  2. **Render phase** — `entry.publish_via(context)` calls `Produce::Render#bytes_for(target:, data:, boot:)` to expand the Mustache template and copy the result to the publish target via `Ports::Publisher`. Returns `nil` if no publish is configured (skipped).
224
224
 
225
- `AsyncRunner` (nested in `Engine`) enqueues reactive produce when `entry_written` fires, then drains before the process exits via an `at_exit` hook. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
225
+ Reactive produce is enqueued as `materialize` jobs onto `Ports::Queue` when `entry_written`/`entry_deleted`/`entry_renamed` fires; a worker (`drain`/`serve`) runs them through `converge`. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
226
226
 
227
227
  ## Hook payload contract
228
228