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
@@ -1,13 +1,13 @@
1
1
  # Textus architecture
2
2
 
3
3
  > **Explanation** · for contributors · **read this first** for orientation before SPEC
4
- > **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/fetch paths) · **reviewed** 2026-06 (v0.43)
4
+ > **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/produce paths) · **reviewed** 2026-06 (v0.45)
5
5
 
6
6
  ```mermaid
7
7
  flowchart TD
8
8
  interface["Interface — CLI verbs · MCP gate (JSON-RPC)"]
9
- application["Application — Call · Container · Dispatcher · RoleScope<br/>read/ · write/ · maintenance/ use cases · envelope IO"]
10
- domain["Domain — Permission · Freshness · Staleness<br/>Policy (Guard · GuardFactory · BaseGuards · Evaluation · Fetch · Matcher · Predicates)"]
9
+ application["Application — Call · Container · Dispatcher · RoleScope<br/>read/ · write/ · maintenance/ · produce/ use cases · envelope IO"]
10
+ domain["Domain — Permission · Freshness<br/>Policy (Guard · GuardFactory · BaseGuards · Evaluation · Fetch · Matcher · Predicates)"]
11
11
  infra["Infrastructure — Store · FileStore · Manifest · Schemas<br/>Ports · Hooks · Entry format strategies"]
12
12
  interface --> application
13
13
  application --> domain
@@ -38,12 +38,11 @@ MCP/Ruby, `view(:cli)` for the operator envelope); declarative `source:`/
38
38
  wrap the single dispatch site (`RoleScope#dispatch_bound`) for stateful verbs;
39
39
  and `cli_default:` declares a CLI default that diverges from the agent default.
40
40
  `CLI::Runner` generates a command per `:cli` contract, dispatching `contract.verb`
41
- by construction. Only verbs with genuine *behavior* — `put` (fetch
42
- orchestration), `get` (UnknownKey + resolver suggestions, CLI-only), `build`
43
- (actor-role resolution + BuildLock), the `fetch`/`fetch_all` workers, and the
44
- `boot`/`doctor` composite reports stay hand-authored, plus commands with no
45
- dispatcher verb (`init`, `hook`, `mcp serve`, `schema diff/init`). Total
46
- reconciliation specs make name/dispatch/facet drift unrepresentable.
41
+ by construction. Only verbs with genuine *behavior* — `put` (entry persistence),
42
+ `get` (UnknownKey + resolver suggestions, CLI-only), and `doctor` (not yet
43
+ generatable) stay hand-authored, plus commands with no dispatcher verb (`init`,
44
+ `hook`, `mcp serve`, `schema diff/init`). `boot` is auto-generated from its
45
+ contract. Total reconciliation specs make name/dispatch/facet drift unrepresentable.
47
46
 
48
47
  **Application**
49
48
 
@@ -55,13 +54,13 @@ Dispatcher (static VERBS table: verb → use-case)
55
54
  RoleScope (Store#as(role) — forwards verb calls)
56
55
 
57
56
  read/{get,list,where,uid,schema_envelope,
58
- deps,rdeps,published,stale,validate_all,boot,doctor,
57
+ deps,rdeps,published,validate_all,boot,doctor,
59
58
  freshness,audit,blame,rule_explain,rule_list,pulse}.rb
60
- write/{put,delete,mv,accept,reject,build,
61
- materializer,intake_fetch,retention_sweep,
62
- fetch_worker,fetch_orchestrator,fetch_all}
63
- maintenance/{migrate,key_mv_prefix,key_delete_prefix,
59
+ write/{put,key_delete,key_mv,accept,reject,propose}.rb
60
+ maintenance/{drain,serve,worker,key_mv_prefix,key_delete_prefix,
64
61
  zone_mv,rule_lint}.rb
62
+ produce/{engine,events,render,
63
+ acquire/{intake,handler,projection,serializer}}.rb
65
64
  envelope/io/{reader,writer}.rb (split: parse vs persist)
66
65
  projection.rb
67
66
  ```
@@ -70,8 +69,7 @@ projection.rb
70
69
 
71
70
  ```
72
71
  Permission (write predicate per zone)
73
- Freshness::{Policy,Verdict,Evaluator}
74
- Staleness (Generator/Intake checks)
72
+ Freshness::{Verdict,Evaluator}
75
73
  Action Outcome Sentinel
76
74
  Policy::{Guard,GuardFactory,BaseGuards,Evaluation,Fetch,Matcher,HandlerAllowlist,
77
75
  Predicates::{ZoneWritableBy,SchemaValid,AuthorHeld,TargetIsCanon,EtagMatch,FreshWithin}}
@@ -87,7 +85,7 @@ Storage::FileStore (bytes-only port: read/write/delete/
87
85
  Manifest (Data, Resolver, Policy, Rules)
88
86
  Schemas (eager-load cache)
89
87
  Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
90
- Fetch::Lock,Fetch::Detached,BuildLock}
88
+ BuildLock,Queue,ProduceOnWriteSubscriber,SentinelStore}
91
89
  Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
92
90
  Signature,Builtin,ErrorLog}
93
91
  Entry::{Markdown,Json,Yaml,Text} (format strategies)
@@ -125,7 +123,7 @@ reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
125
123
 
126
124
  Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
127
125
 
128
- - `Write::FetchOrchestrator` — composes `FetchWorker` with the freshness `Action` returned by `Domain::Freshness`.
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).
129
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`.
130
128
 
131
129
  ## Container
@@ -139,7 +137,7 @@ Container = Data.define(
139
137
  )
140
138
  ```
141
139
 
142
- The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: <Container>` — field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
140
+ The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_handler`, `:transform_rows`, `:validate`) receive `caps: <Container>` — field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
143
141
 
144
142
  ## Ports
145
143
 
@@ -151,9 +149,9 @@ Ports are infrastructure adapters with an interface defined by the domain. Each
151
149
  | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
152
150
  | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
153
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. |
154
- | `Ports::Fetch::Lock` | Non-blocking `flock`-backed lock per key prevents concurrent fetch workers from racing on the same entry. |
155
- | `Ports::Fetch::Detached` | Spawns a background thread for async fetch; the caller receives a `fetch_backgrounded` event instead of blocking. |
156
- | `Ports::BuildLock` | Process-exclusive `flock` guard over the materializer build pipeline. Raises `BuildInProgress` if a build is already running. |
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`/`entry_deleted`/`entry_renamed`; enqueues `materialize` jobs onto `Ports::Queue` for reactive re-produce after any write/delete/rename. |
154
+ | `Ports::SentinelStore` | Reads and writes the per-target sentinel file that `Publisher` uses to detect unmanaged overwrites. |
157
155
 
158
156
  Application use cases access ports only through `Container` fields — never through the raw `Store`.
159
157
 
@@ -188,43 +186,49 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
188
186
 
189
187
  ## Read path (`store.get(key)`)
190
188
 
191
- `Read::Get` is the single public read verb (ADR 0062). It is read-through by default: it returns the freshest obtainable envelope, fetching on a stale verdict per the entry's fetch rule, and degrading to a pure on-disk result when the key has no fetch rule. An optional `fetch: false` flag (CLI `--no-fetch`, MCP `{fetch:false}`) forces a pure on-disk read.
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).
192
190
 
193
191
  1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
194
- 2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The contract declares `arg :fetch, default: true`, injected by `Contract::Binder.bind` at the single verb-dispatch chokepoint (`RoleScope#dispatch_bound`) for every surface — so the public verb is always read-through unless the caller explicitly passes `fetch: false`.
195
- 3. `Read::Get#call(key, fetch: false)` runs the pure read sub-step inline: 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 fetch rule, the envelope is annotated fresh and returned immediatelyno orchestrator is involved.
196
- 4. If `fetch: true` and the verdict is stale and the entry's fetch rule demands action, `Read::Get` hands off to `Write::FetchOrchestrator` (built lazily — a pure `fetch: false` call never touches the orchestrator). The orchestrator executes the fetch policy's `Action` (`sync`, `timed_sync`, `detached`, …) and returns an `Outcome`.
197
- 5. The outcome is mapped back to an envelope: `Fetched` → fresh envelope from the write; `Detached` → original envelope with `fetching: true`; `Failed` → original envelope with `fetch_error` set; `Skipped` → original envelope unchanged.
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 `drain` does.
198
194
 
199
- The pure read is `Read::Get#call(key, fetch: false)`it is the safe default for direct in-process callers (accept/reject/publish, materializer, uid, validate_all/validator, schema/tools, hooks/context) that must never trigger a fetch. They construct `Read::Get` directly, bypassing the dispatch injection that sets `fetch: true`. The prior separate read-through path `get_or_fetch` and the separate pure class `Read::GetEntry` were both unified into the one `Read::Get` class (ADR 0062 amendment).
195
+ Because the read is always pure, every callerinteractive 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).
200
196
 
201
197
  ## Write path (`store.put(key, ...)`)
202
198
 
203
199
  1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
204
200
  2. `Write::Put#call` validates the key, resolves the manifest entry, builds `GuardFactory.for(:put, key)` and calls `Guard#check!(eval)` (topology is predicate #0, `zone_writable_by`) — raises `WriteForbidden` if the topology gate denies, `GuardFailed` if any other predicate fails.
205
201
  3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
206
- 4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
202
+ 4. Publishes `:entry_written` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
207
203
 
208
- `Write::{Delete,Mv,Accept,Reject,Build}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
204
+ `Write::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
209
205
 
210
- `Write::Mv` 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.
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.
211
207
 
212
- ## Fetch path (`store.fetch(key)`)
208
+ ## Produce path (`drain`/`serve` + reactive `entry_written`)
213
209
 
214
- 1. CLI `Verb::Fetch` calls `store.fetch(key, role: "automation")`.
215
- 2. `Write::FetchWorker#run(key)`:
216
- - Resolves the manifest entry, looks up the intake handler via `container.rpc.callable(:resolve_intake, mentry.handler)`.
217
- - Publishes `:fetch_started` with the hook context.
218
- - Invokes the handler under a 30s thread-join deadline.
219
- - On any error: publishes `:fetch_failed`, then re-raises.
220
- - On success: builds `GuardFactory.for(:fetch, key)` and calls `Guard#check!`, then persists via `Envelope::IO::Writer#write` directly (no `Put` round-trip); publishes `:entry_fetched` unless etag is unchanged.
221
- 3. `store.fetch_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `FetchWorker#run` per entry; returns `{ fetched:, failed:, skipped: }`.
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
+
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
+
214
+ For each key, `Engine#produce_one`:
215
+
216
+ 1. **Acquire phase** `Produce::Acquire::Intake#run(key)`:
217
+ - Resolves the manifest entry; looks up the intake handler via `container.rpc.callable(:resolve_handler, mentry.handler)`.
218
+ - Publishes `:entry_fetch_started` via `Produce::Events`.
219
+ - Invokes the handler under a timeout deadline.
220
+ - On error: publishes `:entry_fetch_failed`, re-raises.
221
+ - On success: normalises the handler result via its own `normalize_action_result` (keyed on the entry's format), checks guard, persists via `Envelope::IO::Writer`, publishes `:entry_fetched` unless the etag is unchanged.
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
+ 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
+
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.
222
226
 
223
227
  ## Hook payload contract
224
228
 
225
- Pub-sub hooks (`:entry_put`, `:entry_fetched`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
229
+ Pub-sub hooks (`:entry_written`, `:entry_fetched`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
226
230
 
227
- RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
231
+ RPC hooks (`:resolve_handler`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
228
232
 
229
233
  ## Agent surface (boot + pulse + MCP)
230
234
 
@@ -49,78 +49,83 @@ Tooling around `git blame` or audit logs may filter on owner; the gem itself onl
49
49
 
50
50
  ## Derived entries
51
51
 
52
- A derived entry declares a `compute:` block with a `kind:` discriminator. Two kinds:
52
+ A derived entry declares a `source:` block with a `from:` discriminator (ADR 0093/0094). A `source:` acquires **data** — it never renders; rendering is a publish concern (below). Two `from:` values for derived entries:
53
53
 
54
- **`compute: { kind: projection }`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
54
+ **`source: { from: project }`** — textus computes the entry's data on `textus drain` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
55
55
 
56
56
  ```yaml
57
- - key: artifacts.catalogs.people
58
- path: artifacts/catalogs/people.md
57
+ - key: artifacts.derived.people
58
+ path: artifacts/derived/people.md
59
59
  zone: artifacts
60
60
  schema: null
61
61
  owner: automation:catalog-people
62
- compute:
63
- kind: projection
62
+ source:
63
+ from: project
64
64
  select: knowledge.network.org # prefix or list of prefixes
65
65
  pluck: [name, relationship, org]
66
66
  sort_by: name
67
- template: people.mustache # under .textus/templates/
67
+ format: json # the stored form is data
68
68
  publish:
69
- to: [docs/people.md] # optional repo-relative byte-copy targets
69
+ - { to: docs/people.md, template: people.mustache } # render the data through a template
70
70
  ```
71
71
 
72
- **`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
72
+ **`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `converge` (default: `automation`).
73
73
 
74
74
  ```yaml
75
- - key: artifacts.catalogs.skills
76
- path: artifacts/catalogs/skills.md
75
+ - key: artifacts.derived.skills
76
+ path: artifacts/derived/skills.md
77
77
  zone: artifacts
78
78
  owner: automation:catalog-skills
79
- compute:
80
- kind: external
79
+ source:
80
+ from: command
81
81
  command: "rake catalog:skills" # informational; the automation invokes it
82
82
  sources: [knowledge.projects, knowledge.network]
83
83
  ```
84
84
 
85
- The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
85
+ The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `source.sources` — same list, recorded twice so a diff proves what was consumed.
86
86
 
87
- Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Transforms (`compute.transform:`) and subtree publishing (`publish: { tree: }`) are also covered there.
87
+ Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Data transforms (`source.transform:`) and publishing (a `publish:` list of `{ to, template? }` / `{ tree }` targets — render or copy the data) are covered in §5.2–§5.3.
88
88
 
89
89
  ## Intake and freshness
90
90
 
91
- External inputs land via `:resolve_intake` hooks, not shell commands. Each intake entry names a registered handler; refresh is on demand via a read-through `get`:
91
+ External inputs land via `:resolve_handler` hooks, not shell commands. Each intake entry declares `source: { from: handler, handler: <name>, ttl: <dur> }`; re-pull is system-pushed via `drain` (scheduled sweep) and `hook run` (event push) — a `get` never refreshes (ADR 0089):
92
92
 
93
93
  ```sh
94
- textus get feeds.notion.roadmap --as=automation # refreshes if stale
95
94
  textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
95
+ textus drain --as=automation # re-pulls every intake entry past its source.ttl
96
96
  ```
97
97
 
98
- Lifecycle budgets live in the top-level `rules:` block, matched by glob:
98
+ The re-pull cadence is the entry's own `source.ttl`. Age-based garbage collection is the orthogonal `retention:` rule slot in the top-level `rules:` block, matched by glob (`{ ttl, action: drop | archive }`); the two compose — re-pull hourly *and* archive at 90 days:
99
99
 
100
100
  ```yaml
101
+ - key: artifacts.feeds.notion.events
102
+ kind: produced # produce-method (intake) read from source.from: handler
103
+ zone: artifacts
104
+ source: { from: handler, handler: notion, config: { ... }, ttl: 6h }
105
+
101
106
  rules:
102
- - match: feeds.notion.**
103
- lifecycle: { ttl: 6h, on_expire: refresh } # refresh | warn | drop | archive
107
+ - match: artifacts.feeds.notion.**
108
+ retention: { ttl: 90d, action: archive }
104
109
  ```
105
110
 
106
- A typical scheduled integration reads each expired feed (a read-through `get`
107
- refreshes it in-process):
111
+ A typical scheduled integration runs `drain` on a cron to re-pull every
112
+ expired feed:
108
113
 
109
114
  ```sh
110
- textus get feeds.notion.roadmap --as=automation # in cron / CI
115
+ textus drain --as=automation # in cron / CI — re-pulls all stale feeds
111
116
  ```
112
117
 
113
118
  See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
114
119
 
115
120
  ### Read vs. refresh
116
121
 
117
- There is one public read operation (ADR 0062):
122
+ There is one public read operation, and it is pure (ADR 0089):
118
123
 
119
124
  | Operation | Behaviour | Use for |
120
125
  |-----------|-----------|---------|
121
- | `ops.get` | Read-through by defaultrefreshes on stale per the entry's `lifecycle` rule when `on_expire: refresh`; degrades to a pure on-disk read when the key has no lifecycle rule. Pass `fetch: false` (CLI `--no-fetch`, MCP `{fetch:false}`) for an explicit pure read | all callers, including interactive reads, dashboards, and scripts that want the freshest obtainable envelope |
126
+ | `ops.get` | A pure on-disk read annotated with a freshness verdict it NEVER ingests, regardless of the entry's `action`. A stale `refresh` entry reads back stale until the next `drain`. | every caller interactive reads, dashboards, scripts, and internal pipelines (materializer, projection, schema tooling, accept/reject/publish, uid, validator) |
122
127
 
123
- Build pipelines and other internal callers that must never trigger a refresh (materializer, projection, schema tooling, accept/reject/publish, uid, validator) construct `Read::Get` directly with the method default `fetch: false` a pure, orchestrator-free read. They bypass the verb-dispatch injection that sets `fetch: true`, so they always get pure reads without any extra argument.
128
+ Refreshing a stale entry is `drain`'s job (or a `hook run` event), never a read's so no caller can accidentally trigger network I/O by reading.
124
129
 
125
130
  ## Body content
126
131
 
data/lib/textus/boot.rb CHANGED
@@ -26,14 +26,11 @@ module Textus
26
26
  "propose changes by writing #{manifest.policy.queue_zone}.* entries with --as=#{name} " \
27
27
  "and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
28
28
  end,
29
- fetch: lambda do |name, manifest|
30
- "fetch #{zone_label(manifest, :quarantine, "quarantine")} entries with " \
31
- "'textus fetch KEY --as=#{name}' (uses the entry's declared action)"
32
- end,
33
- build: lambda do |_name, manifest|
34
- derived = zone_label(manifest, :derived, "derived")
35
- "'textus build' computes #{derived} entries from projections; " \
36
- "#{derived} files are never hand-edited"
29
+ converge: lambda do |_name, manifest|
30
+ machine = zone_label(manifest, :machine, "machine")
31
+ "'textus drain' materializes derived #{machine} entries from their sources and " \
32
+ "refreshes stale intake #{machine} entries from their declared source; " \
33
+ "derived files are never hand-edited (reactive on canon writes, or a full pass on demand)"
37
34
  end,
38
35
  }.freeze
39
36
 
@@ -87,14 +84,15 @@ module Textus
87
84
  { "name" => "put" },
88
85
  { "name" => "propose" },
89
86
  { "name" => "accept" },
87
+ { "name" => "enqueue" },
90
88
  { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
91
- { "name" => "build" },
92
- { "name" => "tend" },
89
+ { "name" => "drain" },
93
90
  { "name" => "audit" },
94
91
  { "name" => "blame" },
95
92
  { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
96
93
  { "name" => "doctor" },
97
94
  { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
95
+ { "name" => "jobs" },
98
96
  { "name" => "pulse" },
99
97
  { "name" => "capabilities" },
100
98
  ].freeze
@@ -146,11 +144,11 @@ module Textus
146
144
 
147
145
  # Recipes reference verbs, not a transport's CLI strings (ADR 0056): every
148
146
  # step names a verb the agent can call (each transport frames it — CLI as
149
- # `textus get KEY`, MCP as the `get` tool) or is a plain build step. This
147
+ # `textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This
150
148
  # keeps shell lines out of the surface an MCP agent reads.
151
149
  def self.recipes(manifest)
152
150
  queue = manifest.policy.queue_zone
153
- feeds = zone_label(manifest, :quarantine, "the quarantine zone")
151
+ feeds = zone_label(manifest, :machine, "the machine zone")
154
152
  {
155
153
  "read" => {
156
154
  "purpose" => "find and read an entry",
@@ -176,11 +174,11 @@ module Textus
176
174
  "accept #{queue}.KEY — promotes the proposal into its target zone",
177
175
  ],
178
176
  },
179
- "fetch" => {
180
- "purpose" => "refresh stale quarantine-zone caches from their declared intake",
177
+ "drain" => {
178
+ "purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
181
179
  "steps" => [
182
180
  "pulse — its `stale` list names entries past their ttl",
183
- "fetch_all (zone: #{feeds}) — re-pull the stale entries",
181
+ "drain (zone: #{feeds}) — re-pull the stale entries",
184
182
  ],
185
183
  },
186
184
  }
@@ -240,7 +238,7 @@ module Textus
240
238
 
241
239
  def self.entries_for(manifest)
242
240
  manifest.data.entries.map do |e|
243
- derived = manifest.policy.derived_zone?(e.zone)
241
+ derived = e.derived?
244
242
  {
245
243
  "key" => e.key,
246
244
  "zone" => e.zone,
@@ -249,7 +247,7 @@ module Textus
249
247
  "owner" => e.owner,
250
248
  "format" => e.format,
251
249
  "derived" => derived,
252
- "intake" => e.is_a?(Textus::Manifest::Entry::Intake),
250
+ "intake" => e.intake?,
253
251
  "publish_to" => Array(e.publish_to),
254
252
  }
255
253
  end
data/lib/textus/call.rb CHANGED
@@ -9,7 +9,7 @@ module Textus
9
9
  new(
10
10
  role: role.to_s,
11
11
  correlation_id: correlation_id || SecureRandom.uuid,
12
- now: now || Textus::Ports::Clock.now,
12
+ now: now || Textus::Ports::Clock.new.now,
13
13
  dry_run: dry_run,
14
14
  )
15
15
  end
@@ -130,18 +130,23 @@ module Textus
130
130
  # — behavior the generic projection cannot express (ADR 0068/0069):
131
131
  # get — raises UnknownKey with resolver suggestions (a CLI-only
132
132
  # affordance; the agent surface deliberately returns nil)
133
- # put — IntakeFetch read-through orchestration on --fetch
134
- # build auto-resolves the build-capability actor role (not --as) and
135
- # serializes under BuildLock; the role resolution is policy, not
136
- # a projection (around: covers only the lock)
137
- BEHAVIORAL_HATCHES = %i[get put build].freeze
133
+ # put — reads the entry JSON from --stdin (ADR 0089: just stores bytes,
134
+ # no --fetch transform)
135
+ # (build removed in ADR 0087: materialization is system-pushed via drain/serve)
136
+ BEHAVIORAL_HATCHES = %i[get put].freeze
138
137
 
139
138
  # Contract verbs whose CLI is a plain `< Verb` command, not a projection at
140
- # all — composite reports assembled outside the contract:
141
- # boot, doctor composite reports
142
- # (fetch/fetch_all were removed in ADR 0079: FetchWorker is now internal,
143
- # driven by get's read-through orchestrator and the tend sweep.)
144
- NON_PROJECTED_CLI = %i[boot doctor].freeze
139
+ # all — composite reports assembled outside the contract.
140
+ # (boot removed: its contract carries surfaces :cli + the :lean arg, so the
141
+ # generic projection now generates it; the hand-authored CLI::Verb::Boot is
142
+ # deleted in ADR 0101.)
143
+ # (doctor retained: hand-authored to preserve --check=NAME flag spelling and
144
+ # the exit_code: res["ok"] ? 0 : 1 behavior — two things the generic
145
+ # projection cannot yet express; kept in ADR 0101 pending a future pass.)
146
+ # (fetch/fetch_all were removed in ADR 0079: Produce::Acquire::Intake is now internal,
147
+ # driven by the converge sweep (drain/serve) and hook run — ADR 0089 removed the
148
+ # read-through that once also drove it.)
149
+ NON_PROJECTED_CLI = %i[doctor].freeze
145
150
 
146
151
  # The installer skips generation for either category.
147
152
  HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
@@ -4,12 +4,10 @@ module Textus
4
4
  class Get < Runner::Base
5
5
  self.spec = Textus::Read::Get.contract
6
6
  option :as_flag, "--as=ROLE"
7
- option :no_fetch, "--no-fetch"
8
7
 
9
8
  def invoke(store)
10
9
  key = positional.shift or raise UsageError.new("get requires a key")
11
- kw = no_fetch.nil? ? {} : { fetch: false }
12
- result = session_for(store).get(key, **kw)
10
+ result = session_for(store).get(key)
13
11
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
14
12
 
15
13
  emit(result.to_h_for_wire)
@@ -31,7 +31,7 @@ module Textus
31
31
  Role.resolve(flag: as_flag, env: ENV, root: store.root)
32
32
 
33
33
  begin
34
- Textus::Write::IntakeFetch.invoke(
34
+ Textus::Produce::Acquire::Handler.invoke(
35
35
  caps: store.container, handler: name, config: {}, args: args, label: "hook run",
36
36
  )
37
37
  rescue Textus::Error
@@ -6,7 +6,6 @@ module Textus
6
6
 
7
7
  option :as_flag, "--as=ROLE"
8
8
  option :use_stdin, "--stdin"
9
- option :fetch_name, "--fetch=NAME"
10
9
 
11
10
  def invoke(store)
12
11
  key = positional.shift or raise UsageError.new("put requires a key")
@@ -14,25 +13,10 @@ module Textus
14
13
 
15
14
  role = resolved_role(store)
16
15
 
17
- raw = @stdin.read
18
- payload =
19
- if fetch_name
20
- result = Textus::Write::IntakeFetch.invoke(
21
- caps: store.container, handler: fetch_name,
22
- config: { "bytes" => raw }, args: {}, label: "fetch"
23
- )
24
- basename = key.split(".").last
25
- {
26
- "_meta" => {
27
- "name" => basename,
28
- "last_fetched_at" => Time.now.utc.iso8601,
29
- "fetched_with" => fetch_name,
30
- }.merge(result[:_meta] || result["_meta"] || {}),
31
- "body" => result[:body] || result["body"] || "",
32
- }
33
- else
34
- JSON.parse(raw)
35
- end
16
+ # put only stores the stdin JSON (ADR 0089): no transform-on-write.
17
+ # Ingest (running a handler over bytes) is system-pushed via drain/serve
18
+ # and hook run, never a put flag.
19
+ payload = JSON.parse(@stdin.read)
36
20
 
37
21
  meta = payload["_meta"] || {}
38
22
  body = payload["body"] || ""
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ # Launches the convergence daemon in the current process. Blocks forever;
5
+ # reclaims crashed leases and drains the queue each tick (Phase 3 adds
6
+ # scheduled TTL re-pull/sweep). CLI-only — agents enqueue work, they do not
7
+ # run daemons. Acts as the automation role (the build authority).
8
+ class Serve < Verb
9
+ command_name "serve"
10
+
11
+ def call(store)
12
+ call = Textus::Call.build(role: Textus::Role::AUTOMATION)
13
+ Textus::Maintenance::Serve.new(container: store.container, call: call).run
14
+ 0
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/textus/cli.rb CHANGED
@@ -122,9 +122,7 @@ module Textus
122
122
  textus list [--prefix=KEY] [--zone=Z]
123
123
  textus where KEY
124
124
  textus get KEY
125
- textus put KEY --stdin [--fetch=NAME] --as=ROLE
126
- textus fetch KEY
127
- textus fetch all [--prefix=KEY] [--zone=Z]
125
+ textus put KEY --stdin --as=ROLE
128
126
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
129
127
  textus blame KEY [--limit=N]
130
128
  textus doctor
@@ -11,8 +11,7 @@ module Textus
11
11
  key_mv: Textus::Write::KeyMv,
12
12
  accept: Textus::Write::Accept,
13
13
  reject: Textus::Write::Reject,
14
- build: Textus::Write::Build,
15
-
14
+ enqueue: Textus::Write::Enqueue,
16
15
  # Read
17
16
  get: Textus::Read::Get,
18
17
  list: Textus::Read::List,
@@ -32,12 +31,13 @@ module Textus
32
31
  doctor: Textus::Read::Doctor,
33
32
  boot: Textus::Read::Boot,
34
33
  capabilities: Textus::Read::Capabilities,
34
+ jobs: Textus::Read::Jobs,
35
35
 
36
36
  # Maintenance
37
37
  zone_mv: Textus::Maintenance::ZoneMv,
38
38
  key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
39
39
  key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
40
- tend: Textus::Maintenance::Tend,
40
+ drain: Textus::Maintenance::Drain,
41
41
  rule_lint: Textus::Maintenance::RuleLint,
42
42
  }.freeze
43
43
 
@@ -8,17 +8,18 @@ module Textus
8
8
  # verb reported.
9
9
  class GeneratorDrift < Check
10
10
  def call
11
- gen = Textus::Domain::Staleness::GeneratorCheck.new(
11
+ gen = Textus::Domain::Freshness::Evaluator.new(
12
12
  manifest: manifest,
13
13
  file_stat: Textus::Ports::Storage::FileStat.new,
14
+ clock: Textus::Ports::Clock.new,
14
15
  )
15
- manifest.data.entries.flat_map { |m| gen.rows_for(m) }.map do |row|
16
+ manifest.data.entries.flat_map { |m| gen.drift_rows(m) }.map do |row|
16
17
  {
17
18
  "code" => "generator_drift",
18
19
  "level" => "warning",
19
20
  "subject" => row["key"],
20
21
  "message" => row["reason"],
21
- "fix" => "rebuild the entry: `textus build`",
22
+ "fix" => "rematerialize the entry: `textus drain`",
22
23
  }
23
24
  end
24
25
  end
@@ -8,7 +8,7 @@ module Textus
8
8
  def call
9
9
  out = []
10
10
  manifest.data.entries.each do |mentry|
11
- next unless mentry.is_a?(Textus::Manifest::Entry::Intake)
11
+ next unless mentry.intake?
12
12
 
13
13
  handler = mentry.handler
14
14
 
@@ -6,15 +6,15 @@ module Textus
6
6
 
7
7
  def call
8
8
  declared = collect_declared_handlers
9
- registered = rpc.names(:resolve_intake).to_set
9
+ registered = rpc.names(:resolve_handler).to_set
10
10
 
11
11
  out = (declared - registered).map do |name|
12
12
  {
13
13
  "code" => "intake.handler_missing",
14
14
  "level" => "error",
15
15
  "subject" => name.to_s,
16
- "message" => "manifest references intake handler '#{name}' but no resolve_intake hook for '#{name}' is registered",
17
- "fix" => "create .textus/hooks/#{name}.rb with `Textus.hook { |reg| reg.on(:resolve_intake, :#{name}) { ... } }`",
16
+ "message" => "manifest references intake handler '#{name}' but no resolve_handler hook for '#{name}' is registered",
17
+ "fix" => "create .textus/hooks/#{name}.rb with `Textus.hook { |reg| reg.on(:resolve_handler, :#{name}) { ... } }`",
18
18
  }
19
19
  end
20
20
 
@@ -23,7 +23,7 @@ module Textus
23
23
  "code" => "intake.handler_orphan",
24
24
  "level" => "warning",
25
25
  "subject" => name.to_s,
26
- "message" => "resolve_intake hook '#{name}' is registered but no manifest entry references it",
26
+ "message" => "resolve_handler hook '#{name}' is registered but no manifest entry references it",
27
27
  "fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
28
28
  }
29
29
  end
@@ -36,7 +36,7 @@ module Textus
36
36
  def collect_declared_handlers
37
37
  set = Set.new
38
38
  manifest.data.entries.each do |mentry|
39
- set << mentry.handler.to_sym if mentry.is_a?(Textus::Manifest::Entry::Intake)
39
+ set << mentry.handler.to_sym if mentry.intake?
40
40
  end
41
41
  set
42
42
  end