textus 0.8.1 → 0.10.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +50 -22
  4. data/SPEC.md +194 -63
  5. data/docs/architecture.md +22 -4
  6. data/docs/conventions.md +24 -17
  7. data/lib/textus/application/context.rb +44 -0
  8. data/lib/textus/application/reads/audit.rb +69 -0
  9. data/lib/textus/application/reads/blame.rb +79 -0
  10. data/lib/textus/application/reads/freshness.rb +77 -0
  11. data/lib/textus/application/reads/get.rb +62 -0
  12. data/lib/textus/application/reads/policy_explain.rb +39 -0
  13. data/lib/textus/application/refresh/all.rb +41 -0
  14. data/lib/textus/application/refresh/orchestrator.rb +69 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +44 -0
  17. data/lib/textus/application/writes/build.rb +116 -0
  18. data/lib/textus/application/writes/delete.rb +36 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +43 -0
  21. data/lib/textus/builder/pipeline.rb +1 -1
  22. data/lib/textus/builder/renderer/json.rb +1 -1
  23. data/lib/textus/builder/renderer/markdown.rb +1 -1
  24. data/lib/textus/builder/renderer/text.rb +1 -1
  25. data/lib/textus/builder/renderer/yaml.rb +1 -1
  26. data/lib/textus/builder/renderer.rb +1 -1
  27. data/lib/textus/cli/group/policy.rb +11 -0
  28. data/lib/textus/cli/verb/accept.rb +2 -2
  29. data/lib/textus/cli/verb/audit.rb +30 -0
  30. data/lib/textus/cli/verb/blame.rb +16 -0
  31. data/lib/textus/cli/verb/build.rb +2 -1
  32. data/lib/textus/cli/verb/delete.rb +2 -2
  33. data/lib/textus/cli/verb/freshness.rb +16 -0
  34. data/lib/textus/cli/verb/get.rb +7 -1
  35. data/lib/textus/cli/verb/hook_run.rb +4 -4
  36. data/lib/textus/cli/verb/mv.rb +1 -2
  37. data/lib/textus/cli/verb/policy_explain.rb +14 -0
  38. data/lib/textus/cli/verb/policy_list.rb +25 -0
  39. data/lib/textus/cli/verb/put.rb +10 -8
  40. data/lib/textus/cli/verb/refresh.rb +2 -2
  41. data/lib/textus/cli/verb/refresh_stale.rb +18 -0
  42. data/lib/textus/cli/verb/reject.rb +14 -0
  43. data/lib/textus/cli/verb.rb +14 -0
  44. data/lib/textus/cli.rb +16 -2
  45. data/lib/textus/composition.rb +72 -0
  46. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  47. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  48. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  49. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  50. data/lib/textus/doctor.rb +7 -1
  51. data/lib/textus/domain/action.rb +9 -0
  52. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  53. data/lib/textus/domain/freshness/policy.rb +18 -0
  54. data/lib/textus/domain/freshness/verdict.rb +12 -0
  55. data/lib/textus/domain/outcome.rb +10 -0
  56. data/lib/textus/domain/permission.rb +15 -0
  57. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  58. data/lib/textus/domain/policy/matcher.rb +51 -0
  59. data/lib/textus/domain/policy/promote.rb +24 -0
  60. data/lib/textus/domain/policy/refresh.rb +48 -0
  61. data/lib/textus/domain/policy.rb +7 -0
  62. data/lib/textus/hooks/builtin.rb +5 -5
  63. data/lib/textus/hooks/dispatcher.rb +15 -1
  64. data/lib/textus/hooks/dsl.rb +18 -0
  65. data/lib/textus/hooks/registry.rb +12 -5
  66. data/lib/textus/infra/clock.rb +9 -0
  67. data/lib/textus/infra/event_bus.rb +27 -0
  68. data/lib/textus/infra/publisher.rb +73 -0
  69. data/lib/textus/infra/refresh/detached.rb +38 -0
  70. data/lib/textus/infra/refresh/lock.rb +44 -0
  71. data/lib/textus/init.rb +71 -28
  72. data/lib/textus/intro.rb +17 -14
  73. data/lib/textus/manifest/entry.rb +39 -13
  74. data/lib/textus/manifest/policies.rb +83 -0
  75. data/lib/textus/manifest.rb +30 -11
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/proposal.rb +4 -21
  78. data/lib/textus/refresh.rb +9 -45
  79. data/lib/textus/store/mover.rb +14 -9
  80. data/lib/textus/store/reader.rb +10 -8
  81. data/lib/textus/store/staleness.rb +5 -17
  82. data/lib/textus/store/validator.rb +46 -20
  83. data/lib/textus/store/writer.rb +51 -14
  84. data/lib/textus/store.rb +30 -10
  85. data/lib/textus/version.rb +1 -1
  86. data/lib/textus.rb +1 -0
  87. metadata +46 -5
  88. data/lib/textus/builder.rb +0 -86
  89. data/lib/textus/cli/verb/stale.rb +0 -14
  90. data/lib/textus/publisher.rb +0 -71
  91. data/lib/textus/store/view.rb +0 -29
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4b96d4cff83e901df4b2871b57f9c86d071b11126fd42e8fc5563abf45ce810
4
- data.tar.gz: 6afa20a10f7ddee98b5b4adbf6eb87218725b5eb1c3f7b181b8bcb462d4fff74
3
+ metadata.gz: 198dc9a4561b79bf4da22a2f890caa5da2764b8d82b1665b506d6c4c8c0e3fe3
4
+ data.tar.gz: dc5333c3605b7b05174f4b290fcb63260aad7ea089da900f0b3325cf3823d83c
5
5
  SHA512:
6
- metadata.gz: 83825a241b91ac10c2024ebdf04ec0f1bb753b8deeded27894fbbbff84f3f654ad6cc4c0faa2bbfcf940b989bc0393d832ae90af837039814fe51e5f7e4c4515
7
- data.tar.gz: 57079b174111a5e89637140d53ca83f5c9b0c5f777c270be9833174c3dd11c7f4d8b8039ef30c69c6f85e1dfb9d78c89dab3eb99cd517b32e1cf7af2ce7ded7a
6
+ metadata.gz: f9e08a7a3fc46732dcd9458114765a54e3f8d2aca08b3ead6b70ecfc2782c0e9fec5eec38fc933cef9582c5943db52973c8bb06e544c02afec468dc50bbc8797
7
+ data.tar.gz: 9cb891ae4ce1d7583af7546e16e1f1a23d8d88af105430d31c2eb3fd4e25af6df2a60c5677c810a3d11f01582d7500a18d681b8996e9f2f5da1948e32ec2a39f
data/CHANGELOG.md CHANGED
@@ -8,6 +8,335 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
8
  (currently `textus/2`, embedded in every envelope as `protocol`). The protocol
9
9
  is additive within a major; a new major would change the wire string.
10
10
 
11
+ ## 0.10.0 — Shim removal, signal-based zone detection, Builder extraction (2026-05-22)
12
+
13
+ ### Breaking — Ruby API
14
+
15
+ - `Textus::Publisher` constant removed. Use `Textus::Infra::Publisher`.
16
+ - `Textus::Store::View` class removed. Use `Textus::Application::Context`
17
+ (constructed via `Composition.context(store, role:)`).
18
+ - `Textus::Builder` class removed as a public entry point. Build logic lives
19
+ in `Textus::Application::Writes::Build`. External callers should use
20
+ `Textus::Composition.writes_build(ctx).call` instead of
21
+ `Textus::Builder.new(store).build`. The `Textus::Builder` namespace is
22
+ retained internally only for nested helpers (`Builder::Pipeline`,
23
+ `Builder::Renderer::*`).
24
+ - `Application::Context` no longer exposes `put` / `delete` / `get` / `list`
25
+ / `where` shim methods. Hook callers that receive a Context via the
26
+ `store:` hook keyword must call `ctx.store.put(...)` etc., and explicitly
27
+ pass `as: ctx.role` for write operations.
28
+ - Intake handler return values must use `_meta:` for frontmatter. The
29
+ previous `frontmatter:` legacy key is no longer accepted.
30
+
31
+ ### Fixed
32
+
33
+ - `textus reject` and `textus refresh-stale` now work correctly for stores
34
+ that use the post-0.9.2 default zone names (`review`, `output`).
35
+ Zone-kind detection is now signal-based (driven by `writable_by:`
36
+ membership), not name-based. Stores using the pre-0.9.2 names (`pending`,
37
+ `derived`) continue to work.
38
+ - Event payloads' `store:` keyword now carries a Context whose
39
+ `correlation_id` matches the event payload's top-level `correlation_id`
40
+ key. Previously the `store:` Context received a fresh, unrelated
41
+ `correlation_id`.
42
+
43
+ ### Added
44
+
45
+ - `Textus::Manifest::Entry#in_generator_zone?` and `#in_proposal_zone?`
46
+ predicates. Internal `derived?` retained as an alias of
47
+ `in_generator_zone?`.
48
+ - `:built` and `:published` events now carry `correlation_id` in the
49
+ payload, matching the existing pattern on `:put` / `:deleted` /
50
+ `:accepted`.
51
+
52
+ ### Removed
53
+
54
+ - Legacy zone-purpose annotations for `canon` / `intake` / `pending` /
55
+ `derived` removed from `Textus::Intro::ZONE_PURPOSES`. Custom-named zones
56
+ continue to get no purpose annotation (existing behavior). Stores still
57
+ using the pre-rename default names will simply not get purpose
58
+ annotations on those zones in `textus intro` output.
59
+ - Dead code: `Textus::Manifest#validate_keys!` removed (had no callers).
60
+
61
+ ### Internal
62
+
63
+ - Builder logic fully extracted into `Application::Writes::Build`.
64
+ - CLI verbs now share `context_for(store)` / `resolved_role(store)`
65
+ helpers on `CLI::Verb`.
66
+ - Internal helpers in `Manifest`, `Doctor`, and `Manifest::Entry` are
67
+ properly marked private.
68
+
69
+ ### Unchanged
70
+
71
+ - Wire protocol stays `textus/2`. Envelope shape unchanged.
72
+ - CLI verbs, their flags, and their JSON output shape — unchanged.
73
+ - Manifest YAML schema — unchanged.
74
+ - Event names — unchanged (payload gains `correlation_id` on `:built` /
75
+ `:published`, but no existing key is removed or renamed).
76
+ - Hook DSL — unchanged in shape. The `store:` keyword still passes an
77
+ object that responds to `.get`, `.list`, `.where`. The Context's
78
+ role-aware `with_role` is the recommended construction site for hook
79
+ contexts now.
80
+
81
+ ### Migration recipe
82
+
83
+ ```ruby
84
+ # Hook handlers — before 0.10.0
85
+ Textus.hook(:intake, :my_hook) do |store:, config:, args:|
86
+ store.put("inbox.foo", meta: { ... }, body: "...") # used Context shim
87
+ end
88
+
89
+ # Hook handlers — 0.10.0+
90
+ Textus.hook(:intake, :my_hook) do |store:, config:, args:|
91
+ ctx = store # rename for clarity if desired
92
+ ctx.store.put("inbox.foo", meta: { ... }, body: "...", as: ctx.role)
93
+ end
94
+
95
+ # Intake handler returns — before 0.10.0
96
+ { frontmatter: { ... }, body: "..." } # legacy key
97
+
98
+ # Intake handler returns — 0.10.0+
99
+ { _meta: { ... }, body: "..." } # _meta is the canonical key
100
+ ```
101
+
102
+ If you imported the removed constants directly:
103
+
104
+ ```ruby
105
+ # Before
106
+ Textus::Publisher # removed
107
+ Textus::Store::View # removed
108
+ Textus::Builder.new(store).build(key, ...) # removed
109
+
110
+ # After
111
+ Textus::Infra::Publisher
112
+ Textus::Application::Context # via Composition.context(store, role:)
113
+ Textus::Composition.writes_build(ctx).call(key, ...)
114
+ ```
115
+
116
+ ## 0.9.2 — Policies, audit verbs, zone rename (2026-05-22)
117
+
118
+ ### Breaking — manifest YAML
119
+
120
+ - **Top-level `policies:` block added.** Replaces entry-level `intake.ttl` and
121
+ `intake.on_stale`. Hand-edit existing manifests (see migration recipe below);
122
+ no migrator ships with 0.9.2 because the gem is pre-1.0 with no known
123
+ outside upgraders.
124
+ - **Default zone names renamed.** `canon → identity`, `intake → inbox`,
125
+ `pending → review`, `derived → output`. `working` unchanged. Hand-edit
126
+ the manifest + `mv` the zone directories (see recipe below).
127
+ - Custom-named zones are unaffected.
128
+
129
+ ### Breaking — CLI
130
+
131
+ - `textus stale` removed. Use `textus freshness`.
132
+
133
+ ### Added — verbs
134
+
135
+ - `textus freshness [--prefix=K] [--zone=Z]` — per-entry status (ttl, age,
136
+ next_due_at, status: fresh|stale|never_refreshed|no_policy).
137
+ - `textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X]
138
+ [--correlation-id=ID] [--limit=N]` — query `.textus/audit.log`.
139
+ - `textus blame KEY` — audit rows joined with git commit metadata.
140
+ - `textus policy list` — dump effective policies.
141
+ - `textus policy explain KEY` — show per-slot winners and matching blocks.
142
+
143
+ ### Added — domain
144
+
145
+ - `Textus::Domain::Policy::Refresh` — ttl + on_stale value, exports to
146
+ `Domain::Freshness::Policy`. `on_stale` vocab is `warn | sync | timed_sync`
147
+ (unchanged from 0.9.0).
148
+ - `Textus::Domain::Policy::Promote` — promote_requires predicate.
149
+ - `Textus::Domain::Policy::HandlerAllowlist` — allowed intake handlers.
150
+ - `Textus::Domain::Policy::Matcher` — glob match + specificity ranking.
151
+ - `Textus::Manifest::Policies` — collection over policy blocks with
152
+ most-specific-wins resolution.
153
+
154
+ ### Added — doctor checks
155
+
156
+ - `policy_ambiguity` — two blocks of the same specificity matching one key.
157
+ - `handler_allowlist` — intake handler outside its policy's allowlist.
158
+ - `legacy_intake_fields` — `intake.ttl`/`intake.on_stale` still present in
159
+ raw YAML.
160
+
161
+ ### Unchanged
162
+
163
+ - Wire protocol stays `textus/2`. Envelope shape unchanged.
164
+ - Hook DSL, event names, role gate semantics, schema validation unchanged.
165
+ - `on_stale:` vocabulary (`warn | sync | timed_sync`) and its semantics
166
+ (return-stale / block-and-refresh / try-with-deadline) are unchanged —
167
+ policies merely change where the value lives.
168
+ - `:publish` hook (shipped 0.8.2) remains the extension point for custom
169
+ publish targets.
170
+
171
+ ### Migration recipe (hand-edit, no migrator ships)
172
+
173
+ ```sh
174
+ # In your existing .textus/manifest.yaml:
175
+ # 1. Rename zones[].name fields: canon→identity, intake→inbox,
176
+ # pending→review, derived→output.
177
+ # 2. Rewrite every entries[].zone and entries[].path prefix accordingly.
178
+ # 3. Move each entries[].intake.ttl / on_stale / sync_budget_ms into
179
+ # a new top-level policies:[] block keyed by the entry's exact key:
180
+ #
181
+ # policies:
182
+ # - match: "inbox.news.hn"
183
+ # refresh: { ttl: 6h, on_stale: sync }
184
+
185
+ # On disk:
186
+ mv .textus/zones/canon .textus/zones/identity
187
+ mv .textus/zones/intake .textus/zones/inbox
188
+ mv .textus/zones/pending .textus/zones/review
189
+ mv .textus/zones/derived .textus/zones/output
190
+
191
+ # Verify:
192
+ textus doctor
193
+ ```
194
+
195
+ Find-and-replace tips for ad-hoc references in your own files:
196
+
197
+ ```sh
198
+ # README snippets, CI yaml, shell scripts
199
+ sed -i.bak \
200
+ -e 's/\bcanon\b/identity/g' \
201
+ -e 's/\bintake\b/inbox/g' \
202
+ -e 's/\bpending\b/review/g' \
203
+ -e 's/\bderived\b/output/g' \
204
+ -e 's/textus stale/textus freshness/g' \
205
+ README.md CONTRIBUTING.md
206
+ ```
207
+
208
+ ## 0.9.1 — write-path layering + request Context (2026-05-22)
209
+
210
+ ### Changed — internal architecture (no plugin-visible impact)
211
+
212
+ - Promoted `Store::View` to `Application::Context`. The new Context carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. It answers `can_read?(zone)` / `can_write?(zone)` via the new `Domain::Permission` value. `Store::View` remains as a deprecated alias for one release; slated for removal in 0.10.0.
213
+ - Extracted `Domain::Permission` from `Manifest#zone_writers`. Pure predicate value — `allows_read?(role)` / `allows_write?(role)`. Manifest gains `#permission_for(zone_name)` returning a `Permission`.
214
+ - Extracted write-path use cases under `Application::Writes::*`:
215
+ - `Writes::Put` (was `Store::Writer#put` orchestration)
216
+ - `Writes::Delete` (was `Store::Writer#delete` orchestration)
217
+ - `Writes::Build` (was `Builder#build` orchestration)
218
+ - `Writes::Accept` (was `Proposal.accept`)
219
+ - `Writes::Publish` (was direct calls to `Publisher.publish`)
220
+ - `Store::Writer#put` and `#delete` reduced to pure I/O (`#write_envelope_to_disk`, `#delete_envelope_from_disk`). The original methods remain as backward-compat shims that delegate to the use cases.
221
+ - `Builder`, `Proposal`, `Publisher` become thin shims. `Publisher` also moved to `Textus::Infra::Publisher` (its prior location remains as an alias).
222
+ - `Store#get`, `#put`, `#delete` reduced to 2-line shims through the new `Composition` module. `Store` itself no longer imports from `Application::*`.
223
+ - New `Composition` factory module wires Contexts and use cases. CLI verbs construct via `Composition.context(store, role:)` then `Composition.<use_case>(ctx).call(...)`.
224
+ - `Refresh::Worker` and `Refresh::Orchestrator` migrated to take `Context` instead of `store:` + `as:`.
225
+
226
+ ### Added
227
+
228
+ - Every event payload now includes `correlation_id` — a UUID generated once per Context. Hook authors can use this to correlate events within a single request (e.g., a `:refreshed` event and a downstream `:built` event share an ID).
229
+
230
+ ### Deprecated
231
+
232
+ - `Textus::Store::View` — use `Textus::Application::Context`. Removed in 0.10.0.
233
+ - `Textus::Publisher` — use `Textus::Infra::Publisher` or `Textus::Application::Writes::Publish`. Removed in 0.10.0.
234
+
235
+ ### Unchanged
236
+
237
+ - Plugin DSL, manifest YAML schema, CLI verb JSON output, envelope fields, event names, wire protocol — all identical to 0.9.0.
238
+ - No migration needed for plugin authors.
239
+
240
+ ## 0.9.0 — intake, event standardization, read-time freshness, layered architecture (2026-05-22)
241
+
242
+ ### Breaking — manifest schema
243
+ - The `source:` block is renamed to `intake:`. Its inner `fetch:` is renamed to `handler:`. Other inner fields (`config:`, `ttl:`) keep their names.
244
+ - Loading a manifest that still uses `source:` raises a clear migration error.
245
+
246
+ ### Breaking — event names (pub-sub bus)
247
+ - `:fetch` → `:intake` (RPC)
248
+ - `:delete` → `:deleted`
249
+ - `:refresh` → `:refreshed`
250
+ - `:build` → `:built`
251
+ - `:publish` → `:published`
252
+ - `:accept` → `:accepted`
253
+ - `:put`, `:mv`, `:reject`, `:loaded` are unchanged (already past tense).
254
+
255
+ ### Breaking — DSL sugar
256
+ - `Textus.fetch(:name)` → `Textus.intake(:name)`
257
+ - `Textus.refresh`, `.build`, `.publish`, `.delete`, `.accept` rename to past-tense equivalents.
258
+ - The primitive `Textus.hook(event, name)` is unchanged — event symbols update per above.
259
+
260
+ ### Added — read-time freshness
261
+ - Every entry's manifest may declare `intake.on_stale: warn | sync | timed_sync` (default `warn`).
262
+ - `warn` — return stale envelope with `stale: true`, `stale_reason: "…"`; no refresh.
263
+ - `sync` — refresh inline, return fresh envelope.
264
+ - `timed_sync` — attempt sync up to `sync_budget_ms` (default 500ms); if exceeded, fork+detach a child to complete the refresh and return stale + `refreshing: true` to the caller. Unix only; on Windows falls back to `warn`.
265
+ - New envelope fields on `textus get`: `stale`, `stale_reason`, `refreshing`.
266
+
267
+ ### Added — refresh lifecycle events
268
+ - `:refresh_began { key, mode }` fires when refresh begins.
269
+ - `:refresh_failed { key, error_class, error_message }` fires on intake errors.
270
+ - `:refresh_detached { key, started_at, budget_ms }` fires when timed_sync gives up waiting and forks.
271
+
272
+ ### Added — actuator
273
+ - `textus refresh-stale [--prefix=KEY] [--zone=Z]` — refreshes every entry whose TTL has expired. Returns `{ refreshed, failed, skipped }` JSON. Exits non-zero on any failure. Intended for cron / CI.
274
+
275
+ ### Added — doctor check
276
+ - `textus doctor` now verifies every manifest `intake.handler:` resolves to a registered `Textus.intake(:name)`, reports missing handlers as errors, and orphan registrations as warnings.
277
+
278
+ ### Changed — internal architecture (no plugin-visible impact)
279
+ - Internals reorganized into four layers: `domain/` (pure values), `application/` (use cases), `infra/` (adapters), and `cli/` (interface). Plugin DSL, manifest schema, CLI verbs, and envelope shape are unchanged.
280
+ - `Freshness` is split into `Domain::Freshness::Evaluator` (pure) + `Domain::Freshness::Policy#decide` (data-driven) + `Application::Refresh::Orchestrator` (effects).
281
+ - `Refresh.call` is now a one-line shim over `Application::Refresh::Worker.run`. `Refresh::Lock` and `Refresh::Detached` moved to `Infra::Refresh`.
282
+ - `Store#get` now routes through `Application::Reads::Get`. `Store::Reader#get` was reduced to pure I/O (`#read_raw_envelope`) — third-party code calling it directly should switch to `Store#get` for freshness annotations.
283
+
284
+ ### Unchanged
285
+ - Wire protocol stays `textus/2`.
286
+ - `:reduce`, `:check`, `:put` unchanged.
287
+ - The recursive `hooks/**/*.rb` loader from 0.8.2.
288
+
289
+ ### Migration
290
+ 1. In `.textus/manifest.yaml`, replace every `source:` with `intake:` and every `fetch:` inside it with `handler:`. No other inner-field renames needed.
291
+ 2. In hook files, replace `Textus.fetch(:name)` with `Textus.intake(:name)` and the other five pub-sub sugar names with their past-tense equivalents.
292
+ 3. (Optional) Add `on_stale: timed_sync` to entries where you want self-healing reads.
293
+ 4. Wire `textus refresh-stale` into cron / GH Actions for scheduled freshness.
294
+ 5. If you subscribed to the `:refresh_started` event during 0.9.0 betas, rename your handler to `Textus.refresh_began(:name)`.
295
+ 6. If you called `Textus::Refresh::Lock` or `Textus::Refresh::Detached` directly (you probably did not), update to `Textus::Infra::Refresh::Lock` / `Textus::Infra::Refresh::Detached`.
296
+
297
+ ## 0.8.3 — :mv, :reject, :loaded events (2026-05-22)
298
+
299
+ ### Added
300
+ - New `:mv` event — fires after a successful `store.mv`. Payload:
301
+ `{ key:, from_key:, to_key:, envelope: }` where `key:` equals `to_key:`
302
+ so `keys:` glob filters route against the entry's post-move home.
303
+ `:put` and `:delete` remain suppressed for renames; `:mv` is the sole signal.
304
+ - New `:reject` event + `store.reject(pending_key, as: "human")` +
305
+ `textus reject KEY --as=human` CLI verb. Counterpart to `:accept` —
306
+ explicitly discards a proposal. Fires `:delete` then `:reject`.
307
+ - New `:loaded` event — fires exactly once at the tail of `Store#initialize`,
308
+ after all hooks are registered and reader/writer are built. Use for cache
309
+ warmups and one-shot setup. Payload: `store:` only.
310
+
311
+ ## 0.8.2 — Hook DSL sugar + :publish event (2026-05-22)
312
+
313
+ ### Added
314
+ - Per-event hook sugar: `Textus.fetch`, `.reduce`, `.check`, `.put`,
315
+ `.delete`, `.refresh`, `.build`, `.accept`, `.publish`. Each takes
316
+ `(name, **opts, &blk)` and delegates to the existing registry. Block
317
+ signatures are per-event (use `**` to absorb unused kwargs).
318
+ - New `:publish` pub-sub event. Fires once per file written to a repo
319
+ path (both for the fixed-list `publish_to:` case and the `publish_each:`
320
+ per-leaf case). Payload: `{ key:, envelope:, source:, target: }`.
321
+ Listeners can react per-file — e.g. `git add` each published file,
322
+ notify on writes, compute checksums.
323
+ - `.textus/hooks/**/*.rb` — hook files in subdirectories are now loaded.
324
+ Subdirectory names are organizational; the registered event and name
325
+ come from the DSL call, not the file path. Files load in alphabetical
326
+ order by full path.
327
+
328
+ ### Unchanged
329
+ - `Textus.hook(:event, :name, &blk)` primitive — still works, still the
330
+ authoritative entry point.
331
+ - `:build` event semantics — still fires once per derived entry.
332
+ - Registry shape, dispatcher behavior, audit log, wire protocol
333
+ (`textus/2`), envelope shape.
334
+
335
+ ### Example migrations
336
+ The bundled `examples/claude-plugin` was migrated to the new DSL
337
+ (snake_case names, sugar methods). No behavioral change; serves as the
338
+ canonical example.
339
+
11
340
  ## 0.8.1 — Terminology cleanup (2026-05-21)
12
341
 
13
342
  ### Breaking — intro output
data/README.md CHANGED
@@ -14,7 +14,7 @@ Reference implementation in Ruby. Wire format `textus/2`. SPEC: [`SPEC.md`](SPEC
14
14
  Two versions, deliberately independent:
15
15
 
16
16
  - **Protocol wire string:** `textus/2`. Stable; breaking changes require `textus/3`.
17
- - **Gem version:** semver, currently `0.8.0`. The gem version is decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
17
+ - **Gem version:** semver, currently `0.9.2`. The gem version is decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
18
18
 
19
19
  Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
20
20
 
@@ -48,15 +48,17 @@ You get `.textus/` with all five zone directories, baseline schemas, an empty au
48
48
  hooks/ # one .rb per hook
49
49
  sentinels/ # publish bookkeeping
50
50
  zones/
51
- canon/ # human-only — identity, voice, decisions
51
+ identity/ # human-only — identity, voice, decisions
52
52
  working/ # human / ai / script — day-to-day catalog
53
- intake/ # script — declared external inputs (actions)
54
- pending/ # ai + human — proposals awaiting accept
55
- derived/ # build only — computed outputs
53
+ inbox/ # script — declared external inputs (actions)
54
+ review/ # ai + human — proposals awaiting accept
55
+ output/ # build only — computed outputs
56
56
  ```
57
57
 
58
58
  Manifest `path:` fields are relative to `.textus/zones/`. So `working.network.org.jane` lives at `.textus/zones/working/network/org/jane.md`.
59
59
 
60
+ > **Renamed in 0.9.2.** Pre-0.9.2 defaults were `canon`, `intake`, `pending`, `derived`. `working` is unchanged. Upgrading a 0.9.1 store is a hand-edit (see CHANGELOG migration recipe).
61
+
60
62
  Read and write:
61
63
 
62
64
  ```sh
@@ -64,7 +66,9 @@ textus get working.network.org.jane
64
66
  textus list --zone=working
65
67
  echo '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
66
68
  | textus put working.network.org.bob --as=human --stdin
67
- textus stale --zone=derived
69
+ textus freshness --zone=output # per-entry fresh/stale/never_refreshed/no_policy
70
+ textus policy list # show every policy block
71
+ textus audit --limit=20 # query the audit log
68
72
  ```
69
73
 
70
74
  (All verbs return JSON envelopes by default; pass `--format=json` explicitly if you prefer.)
@@ -96,29 +100,33 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
96
100
  | `where K` | Resolve a key to its filesystem path |
97
101
  | `get K` | Full envelope (frontmatter, body, uid, etag, format) |
98
102
  | `schema show K` | Schema bound to an entry |
99
- | `stale [--prefix=K] [--zone=Z]` | List stale derived/intake entries |
103
+ | `freshness [--prefix=K] [--zone=Z]` | Per-entry status (fresh / stale / never_refreshed / no_policy) against `policies:` |
104
+ | `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | Query `.textus/audit.log` |
105
+ | `blame KEY` | Audit rows joined with git commit metadata |
106
+ | `policy list` / `policy explain KEY` | Dump effective policies / per-slot winners for one key |
100
107
  | `deps K` / `rdeps K` | Forward / reverse projection dependencies |
101
108
  | `published` | List `publish_to:` targets and their backing keys |
102
109
  | `doctor --check=schema_violations` | Validate every entry against its schema |
103
- | `hook list [--event=E]` | Registered hooks grouped by event (fetch, reduce, check, put, delete, refresh, build, accept) |
110
+ | `hook list [--event=E]` | Registered hooks grouped by event (intake, reduce, check, put, deleted, refreshed, built, accepted, published, mv, reject, loaded, refresh_began, refresh_failed, refresh_detached) |
104
111
 
105
112
  **Write:**
106
113
 
107
114
  | Verb | Role |
108
115
  |---|---|
109
116
  | `put K --stdin --as=R [--action=NAME]` | per zone |
110
- | `hook run NAME [--key=val] [--as=R]` | per zone written (invoke a registered fetch hook) |
117
+ | `hook run NAME [--key=val] [--as=R]` | per zone written (invoke a registered intake hook) |
111
118
  | `delete K --if-etag=E --as=R` | per zone |
112
119
  | `refresh K --as=script` | per zone (typically `script`) |
113
120
  | `key mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
114
121
  | `build [--prefix=K] [--dry-run]` | `build` |
115
122
  | `accept K --as=human` | `human` only |
123
+ | `reject K --as=human` | `human` only (discards a pending proposal; fires `:reject`) |
116
124
 
117
125
  **Health & maintenance:**
118
126
 
119
127
  | Verb | Purpose |
120
128
  |---|---|
121
- | `doctor` | 8 health checks; `ok: true` when clean |
129
+ | `doctor` | Health checks (manifest, schemas, templates, hooks, illegal keys, sentinels, audit log, policy ambiguity, handler allowlist, legacy intake fields); `ok: true` when clean |
122
130
  | `key migrate [--dry-run]` | Rename files whose basenames violate the strict key grammar |
123
131
 
124
132
  **Scaffolding (human-only):**
@@ -134,11 +142,11 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
134
142
 
135
143
  | Zone | `writable_by` | Purpose |
136
144
  |---|---|---|
137
- | `canon` | `[human]` | Identity, voice, decisions — slow-changing |
145
+ | `identity` | `[human]` | Identity, voice, decisions — slow-changing |
138
146
  | `working` | `[human, ai, script]` | Active project state |
139
- | `intake` | `[script]` | Declared external inputs (actions) |
140
- | `pending` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
141
- | `derived` | `[build]` | Computed outputs from `textus build` |
147
+ | `inbox` | `[script]` | Declared external inputs (actions) |
148
+ | `review` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
149
+ | `output` | `[build]` | Computed outputs from `textus build` |
142
150
 
143
151
  Mismatches return `write_forbidden` with a hint naming the role that *would* be allowed. Every write records the resolved role in `.textus/audit.log`.
144
152
 
@@ -150,20 +158,40 @@ Derived entries declare a `projection:` (`select`, `pluck`, `sort_by`, `limit`,
150
158
 
151
159
  ## Extension points
152
160
 
153
- textus exposes one DSL verb:
161
+ textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path). Events:
162
+
163
+ - `:intake` — bring bytes in from elsewhere (returns `{_meta:, body:}`)
164
+ - `:reduce` — transform rows during projection (returns rows)
165
+ - `:check` — custom doctor check (returns issues)
166
+ - `:put`, `:deleted`, `:refreshed`, `:built`, `:accepted`, `:published`, `:mv`, `:reject`, `:loaded` — react to lifecycle events
167
+ - `:refresh_began`, `:refresh_failed`, `:refresh_detached` — background-refresh lifecycle (0.9.0+)
154
168
 
155
169
  ```ruby
156
- Textus.hook(event, name, **opts) { |args| ... }
170
+ # Inside .textus/hooks/local_file.rb
171
+ Textus.intake(:local_file) do |config:, args:, **|
172
+ path = config["path"] or raise "local-file requires intake.config.path"
173
+ {
174
+ _meta: { "last_refreshed_at" => Time.now.utc.iso8601, "source_path" => path },
175
+ body: File.read(File.expand_path(path)),
176
+ }
177
+ end
157
178
  ```
158
179
 
159
- Drop `.rb` files into `.textus/hooks/`. Events:
180
+ ```ruby
181
+ Textus.reduce(:rank_by_recency) do |rows:, **|
182
+ rows.sort_by { |r| r["updated_at"].to_s }.reverse
183
+ end
184
+ ```
160
185
 
161
- - `:fetch` bring bytes in from elsewhere (returns `{frontmatter:, body:}`)
162
- - `:reduce` — transform rows during projection (returns rows)
163
- - `:check` — custom doctor check (returns issues)
164
- - `:put`, `:delete`, `:refresh`, `:build`, `:accept` — react to lifecycle events
186
+ To keep a batch of stale intake entries current in one shot:
187
+
188
+ ```sh
189
+ textus refresh-stale --prefix=working --zone=intake --as=script
190
+ # or just refresh everything stale in the intake zone:
191
+ textus refresh-stale --zone=intake --as=script
192
+ ```
165
193
 
166
- See SPEC.md §5.10 for the full contract.
194
+ The primitive `Textus.hook(event, name, **opts) { ... }` is also supported. See SPEC.md §5.10 for the full contract.
167
195
 
168
196
  Schemas (`.textus/schemas/<name>.yaml`) declare field shapes, per-field `maintained_by:` ownership, and an `evolution:` block (`added_in`, `deprecated_at`, `migrate_from`). Full contract in SPEC §5.8.
169
197