textus 0.15.0 → 0.20.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +50 -55
  3. data/CHANGELOG.md +486 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +20 -34
  8. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  9. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  10. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  11. data/lib/textus/application/projection.rb +91 -0
  12. data/lib/textus/application/reads/audit.rb +4 -4
  13. data/lib/textus/application/reads/blame.rb +11 -8
  14. data/lib/textus/application/reads/deps.rb +14 -3
  15. data/lib/textus/application/reads/freshness.rb +17 -6
  16. data/lib/textus/application/reads/get.rb +37 -11
  17. data/lib/textus/application/reads/get_or_refresh.rb +8 -8
  18. data/lib/textus/application/reads/list.rb +5 -3
  19. data/lib/textus/application/reads/policy_explain.rb +3 -3
  20. data/lib/textus/application/reads/published.rb +5 -3
  21. data/lib/textus/application/reads/rdeps.rb +15 -3
  22. data/lib/textus/application/reads/schema_envelope.rb +6 -3
  23. data/lib/textus/application/reads/stale.rb +3 -3
  24. data/lib/textus/application/reads/uid.rb +11 -3
  25. data/lib/textus/application/reads/validate_all.rb +12 -3
  26. data/lib/textus/application/reads/validator.rb +84 -0
  27. data/lib/textus/application/reads/where.rb +6 -3
  28. data/lib/textus/application/refresh/all.rb +16 -5
  29. data/lib/textus/application/refresh/orchestrator.rb +9 -9
  30. data/lib/textus/application/refresh/worker.rb +59 -32
  31. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  32. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  33. data/lib/textus/application/writes/accept.rb +36 -13
  34. data/lib/textus/application/writes/delete.rb +13 -15
  35. data/lib/textus/application/writes/envelope_io.rb +166 -0
  36. data/lib/textus/application/writes/materializer.rb +50 -0
  37. data/lib/textus/application/writes/mv.rb +56 -95
  38. data/lib/textus/application/writes/publish.rb +132 -27
  39. data/lib/textus/application/writes/put.rb +17 -20
  40. data/lib/textus/application/writes/reject.rb +18 -9
  41. data/lib/textus/builder/pipeline.rb +21 -15
  42. data/lib/textus/builder/renderer/json.rb +4 -1
  43. data/lib/textus/builder/renderer/markdown.rb +7 -1
  44. data/lib/textus/builder/renderer/yaml.rb +4 -1
  45. data/lib/textus/cli/group/hook.rb +1 -3
  46. data/lib/textus/cli/group/key.rb +1 -4
  47. data/lib/textus/cli/group/refresh.rb +1 -2
  48. data/lib/textus/cli/group/rule.rb +1 -3
  49. data/lib/textus/cli/group/schema.rb +1 -5
  50. data/lib/textus/cli/group.rb +12 -16
  51. data/lib/textus/cli/verb/accept.rb +3 -1
  52. data/lib/textus/cli/verb/audit.rb +3 -1
  53. data/lib/textus/cli/verb/blame.rb +3 -1
  54. data/lib/textus/cli/verb/build.rb +4 -5
  55. data/lib/textus/cli/verb/delete.rb +3 -1
  56. data/lib/textus/cli/verb/deps.rb +3 -1
  57. data/lib/textus/cli/verb/doctor.rb +2 -0
  58. data/lib/textus/cli/verb/freshness.rb +3 -1
  59. data/lib/textus/cli/verb/get.rb +4 -2
  60. data/lib/textus/cli/verb/hook_run.rb +6 -4
  61. data/lib/textus/cli/verb/hooks.rb +8 -5
  62. data/lib/textus/cli/verb/init.rb +2 -0
  63. data/lib/textus/cli/verb/intro.rb +2 -0
  64. data/lib/textus/cli/verb/key_normalize.rb +35 -3
  65. data/lib/textus/cli/verb/list.rb +3 -1
  66. data/lib/textus/cli/verb/mv.rb +4 -1
  67. data/lib/textus/cli/verb/published.rb +3 -1
  68. data/lib/textus/cli/verb/put.rb +5 -4
  69. data/lib/textus/cli/verb/rdeps.rb +3 -1
  70. data/lib/textus/cli/verb/refresh.rb +1 -1
  71. data/lib/textus/cli/verb/refresh_stale.rb +4 -2
  72. data/lib/textus/cli/verb/reject.rb +3 -1
  73. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  74. data/lib/textus/cli/verb/rule_list.rb +3 -0
  75. data/lib/textus/cli/verb/schema.rb +4 -1
  76. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  77. data/lib/textus/cli/verb/schema_init.rb +3 -0
  78. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  79. data/lib/textus/cli/verb/uid.rb +4 -1
  80. data/lib/textus/cli/verb/where.rb +3 -1
  81. data/lib/textus/cli/verb.rb +30 -0
  82. data/lib/textus/cli.rb +18 -27
  83. data/lib/textus/doctor/check/audit_log.rb +1 -1
  84. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -2
  86. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  87. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  88. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  91. data/lib/textus/doctor/check/sentinels.rb +2 -2
  92. data/lib/textus/doctor/check/templates.rb +4 -3
  93. data/lib/textus/doctor.rb +3 -4
  94. data/lib/textus/domain/authorizer.rb +37 -0
  95. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  96. data/lib/textus/domain/freshness/policy.rb +1 -1
  97. data/lib/textus/domain/freshness/verdict.rb +1 -1
  98. data/lib/textus/domain/freshness.rb +40 -0
  99. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  100. data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
  101. data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
  102. data/lib/textus/{store → domain}/staleness.rb +1 -1
  103. data/lib/textus/entry/json.rb +1 -1
  104. data/lib/textus/entry/markdown.rb +1 -1
  105. data/lib/textus/entry/yaml.rb +1 -1
  106. data/lib/textus/envelope.rb +7 -3
  107. data/lib/textus/errors.rb +19 -0
  108. data/lib/textus/hooks/builtin.rb +6 -6
  109. data/lib/textus/hooks/bus.rb +155 -0
  110. data/lib/textus/hooks/context.rb +38 -0
  111. data/lib/textus/hooks/fire_report.rb +23 -0
  112. data/lib/textus/hooks/loader.rb +20 -17
  113. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  114. data/lib/textus/infra/audit_subscriber.rb +43 -0
  115. data/lib/textus/infra/event_bus.rb +3 -3
  116. data/lib/textus/infra/publisher.rb +3 -3
  117. data/lib/textus/infra/refresh/detached.rb +1 -1
  118. data/lib/textus/infra/storage/file_store.rb +26 -0
  119. data/lib/textus/init.rb +14 -11
  120. data/lib/textus/intro.rb +7 -7
  121. data/lib/textus/manifest/entry/base.rb +38 -0
  122. data/lib/textus/manifest/entry/derived.rb +25 -0
  123. data/lib/textus/manifest/entry/intake.rb +19 -0
  124. data/lib/textus/manifest/entry/leaf.rb +16 -0
  125. data/lib/textus/manifest/entry/nested.rb +39 -0
  126. data/lib/textus/manifest/entry/parser.rb +64 -31
  127. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  128. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  129. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  130. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  131. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  132. data/lib/textus/manifest/entry.rb +0 -72
  133. data/lib/textus/manifest/resolution.rb +5 -0
  134. data/lib/textus/manifest/resolver.rb +109 -0
  135. data/lib/textus/manifest/schema.rb +1 -1
  136. data/lib/textus/manifest.rb +4 -100
  137. data/lib/textus/operations.rb +147 -23
  138. data/lib/textus/schema/tools.rb +7 -7
  139. data/lib/textus/schemas.rb +46 -0
  140. data/lib/textus/store.rb +12 -49
  141. data/lib/textus/uid.rb +18 -0
  142. data/lib/textus/version.rb +1 -1
  143. data/lib/textus.rb +17 -1
  144. metadata +31 -23
  145. data/lib/textus/application/writes/build.rb +0 -79
  146. data/lib/textus/dependencies.rb +0 -23
  147. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  148. data/lib/textus/hooks/dispatcher.rb +0 -63
  149. data/lib/textus/hooks/dsl.rb +0 -11
  150. data/lib/textus/hooks/registry.rb +0 -81
  151. data/lib/textus/migrate_keys.rb +0 -187
  152. data/lib/textus/operations/reads.rb +0 -56
  153. data/lib/textus/operations/refresh.rb +0 -27
  154. data/lib/textus/operations/writes.rb +0 -21
  155. data/lib/textus/projection.rb +0 -89
  156. data/lib/textus/refresh.rb +0 -39
  157. data/lib/textus/store/reader.rb +0 -69
  158. data/lib/textus/store/validator.rb +0 -82
  159. data/lib/textus/store/writer.rb +0 -102
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54b9e7266b017d02abba0f6daeba977c580c07f1bdab798ebc7a9b943be555cd
4
- data.tar.gz: 47ebf2a56523dbf33058fe95c45163899a6f7b91500a2f749ce3d731e60ab502
3
+ metadata.gz: 613e04300389a5f5bf058f4e75c15b5ff19f813fb7ef6102ba38ce53ecd43043
4
+ data.tar.gz: 97d121b40ac753af1e04893429db1abfe4044f791fff5ea02de33910a4cfa00c
5
5
  SHA512:
6
- metadata.gz: 76abb1c22c3f519574dfd310a7ff2162b023bbbc8667b2e4dc2c32edbb94d432bf507620116dbcbc1f7ce3328964515ee8eded62abe1c833249b3b2bb1bd9fce
7
- data.tar.gz: a405b5d81159c8dd0638e9ca6f4fca419358792c7a3a29f64cb0b19386553f37aa091f913c65ad078a039205ad6ac0368af681eba455fcb4c1787bcfb213ac05
6
+ metadata.gz: 13d11e92b35b952fc9974293544ab49d50dc6055f7ee80771b33a82f511ba534f6cf3fa8c26a84e886778f6b5ee662a3a540d36c7072c23f2b366540a365e066
7
+ data.tar.gz: 7cbf43d42e37271b73122d5db10a463a1936c39696cc6a5e7eee56646ccdb1c503a645e63b3b40e09bfc911ae531e2258b02d4e715458bcd54a193d9e7ca5b0d
data/ARCHITECTURE.md CHANGED
@@ -3,92 +3,87 @@
3
3
  ```
4
4
  ┌─ Interface ────────────────────────────────────────────────┐
5
5
  │ CLI verbs: ops = Operations.for(store, role:) │
6
- │ ops.reads.<name>.call(...)
7
- ops.writes.<name>.call(...)
8
- ops.refresh.<name>.call(...)
6
+ │ ops.<name>(...) # flat methods, one per use
7
+ # case (put/get/refresh/…)
8
+
9
+ │ Or, for embedders bringing their own ports: │
10
+ │ Operations.new(ctx:, manifest:, file_store:, │
11
+ │ schemas:, audit_log:, bus:, │
12
+ │ registry:, root:, store:) │
9
13
  └──────────────────────┬─────────────────────────────────────┘
10
14
 
11
15
  ┌─ Application ────────▼─────────────────────────────────────┐
12
- │ Context (per-request: store, role, correlation,
13
- clock, dry_run; can_read?/can_write?;
14
- Context.system(store) for infra path)
15
- │ Operations (facade with .reads/.writes/.refresh) │
16
+ │ Context (slim Data: role, correlation_id, now,
17
+ │ dry_run — request state only)
18
+ Operations (flat facade; inline use-case factories)
16
19
  │ │
17
20
  │ reads/{get,list,where,uid,schema_envelope,deps,rdeps, │
18
21
  │ published,stale,validate_all,freshness,audit, │
19
- │ blame,policy_explain}.rb
20
- │ writes/{put,delete,mv,accept,reject,build,publish}.rb
22
+ │ blame,policy_explain,get_or_refresh}.rb
23
+ │ writes/{put,delete,mv,accept,reject,build,publish,
24
+ │ envelope_io}.rb │
21
25
  │ refresh/{worker,orchestrator,all}.rb │
26
+ │ policy/{promotion,predicates/{schema_valid,human_accept}} │
22
27
  └──────────┬───────────────────────────────┬─────────────────┘
23
28
  │ uses domain │ uses ports
24
29
  ┌─ Domain ─▼─────────────────────────────────────────────────┐
30
+ │ Authorizer (manifest + role → allow / deny) │
25
31
  │ Permission (write/read predicate per zone) │
26
32
  │ Freshness::{Policy,Verdict,Evaluator} │
27
33
  │ Action Outcome │
28
- │ Policy::Promotion (proposal accept gates)
34
+ │ Policy::{Promote,Refresh,Matcher,HandlerAllowlist}
29
35
  └──────────────────────────────────────────┬─────────────────┘
30
36
  │ implements
31
37
  ┌─ Infrastructure ─────────────────────────▼─────────────────┐
32
- │ Store (pure adapterfilesystem + hooks)
33
- Reader#{get,list,where,uid,deps,rdeps,published,
34
- stale,validate_all,read_raw_envelope,
35
- │ schema_envelope} │
36
- │ Writer#{write_envelope_to_disk,delete_envelope_from_ │
37
- │ disk,existing_uid_for,ensure_uid, │
38
- │ enforce_name_match!,serialize_for_put} │
39
- │ AuditLog, Staleness, Validator, Sentinel │
38
+ │ Store (composition rootwires ports)
39
+ Storage::FileStore (bytes-only port: read/write/delete/
40
+ exists?/etag)
40
41
  │ Manifest (Entry, Rules, Schema, permission_for) │
41
- Hooks::{Registry,Dispatcher,Loader,Dsl,Builtin}
42
+ Schemas (eager-load cache)
43
+ │ AuditLog │
44
+ │ Hooks::{Registry,Dispatcher,Loader,FireReport} │
42
45
  │ Infra::{Publisher,EventBus,Clock,Refresh::Lock, │
43
- │ Refresh::Detached}
46
+ │ Refresh::Detached,BuildLock,AuditSubscriber}
44
47
  │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
45
48
  └────────────────────────────────────────────────────────────┘
46
49
 
47
50
  Dependency rule: arrows point DOWN. Domain has zero outbound
48
51
  imports. Application imports Domain + Infra (via ports).
52
+ Use cases declare their real ports in their constructor.
49
53
  ```
50
54
 
51
- ## Read path (`ops.reads.get.call(key)`)
55
+ ## Read path (`ops.get(key)`)
56
+
57
+ 1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
58
+ 2. `Operations#get` constructs `Application::Reads::Get.new(ctx:, manifest:, file_store:)` and calls it.
59
+ 3. `Reads::Get#call(key)` resolves the path through `@manifest`, reads bytes via `@file_store`, parses the envelope.
60
+ 4. Looks up the refresh policy via `@manifest.rules_for(key)`. If absent, returns the envelope annotated fresh.
61
+ 5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
62
+
63
+ `ops.get_or_refresh(key)` composes `Reads::Get` with `Refresh::Orchestrator` to optionally refresh on stale — same as 0.18.x.
52
64
 
53
- 1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.reads.get.call(key)`.
54
- 2. `Operations::Reads#get` returns an `Application::Reads::Get.new(ctx:, orchestrator:)` instance bound to the request context.
55
- 3. `Reads::Get#call(key)` reads the bare envelope from disk via `@ctx.store.reader.read_raw_envelope(key)`.
56
- 4. Resolves the manifest rules for the key via `@ctx.store.manifest.rules_for(key)` and extracts the `refresh` policy.
57
- 5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
58
- 6. If fresh → annotate envelope (`stale: false`, `refreshing: false`) and return.
59
- 7. Otherwise `policy.decide(verdict) → Action` (data, not behavior).
60
- 8. `Refresh::Orchestrator#execute(action, key:)` interprets the `Action`:
61
- - `Action::Return` → `Outcome::Skipped`
62
- - `Action::RefreshSync` → run `Refresh::Worker` inline → `Refreshed | Failed`
63
- - `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_backgrounded`, fork+detach child, return `Outcome::Detached`
64
- 9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
65
+ ## Write path (`ops.put(key, ...)`)
65
66
 
66
- ## Write path (`ops.writes.put.call(key, ...)`)
67
+ 1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag:)`.
68
+ 2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
69
+ 3. Delegates persistence to `EnvelopeIO#write`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
70
+ 4. Publishes `:entry_put` via `@bus` with `store: @store`, `key:`, `envelope:`, `role: @ctx.role`, `correlation_id: @ctx.correlation_id`.
67
71
 
68
- 1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.writes.put.call(key, meta:, body:, content:, if_etag:, suppress_events:)`.
69
- 2. `Writes::Put#call` validates the key, resolves the manifest entry, and checks `@ctx.can_write?(mentry.zone)` — raises `WriteForbidden` if denied.
70
- 3. Delegates raw I/O to `Store::Writer#write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag:)`, which:
71
- - Resolves the path via `Manifest#resolve`
72
- - Serializes via `Entry.for_format(...).serialize(...)`
73
- - Validates against schema if declared
74
- - Etag-checks if `if_etag:` provided (raises `EtagMismatch` on conflict)
75
- - Writes to disk via `File.binwrite`
76
- - Appends the audit row
77
- 4. On success, publishes `:entry_put` via the bus, with `store: @ctx.with_role(@ctx.role)`, `key:`, `envelope:`, `correlation_id:`.
72
+ `Writes::{Delete,Mv,Accept,Reject,Build,Publish}` follow the same shape: explicit ports, `Authorizer` for authz, `EnvelopeIO` for persistence (where applicable), event published with `store: real Store + role:` in payload.
78
73
 
79
- The same pattern applies to `Writes::{Delete,Mv,Accept,Reject,Build,Publish}`: each takes a `Context`, checks permissions at the use-case layer, delegates raw I/O to `Store::Writer` or `Infra::Publisher`, and fires the matching event.
74
+ `Writes::Mv` delegates the file-move + audit to `EnvelopeIO#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `EnvelopeIO#write` directly no `Put` bypass.
80
75
 
81
- ## Refresh path (`ops.refresh.worker.run(key)`)
76
+ ## Refresh path (`ops.refresh(key)`)
82
77
 
83
- 1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh.worker.run(key)`.
78
+ 1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh(key)`.
84
79
  2. `Refresh::Worker#run(key)`:
85
- - Resolves the manifest entry, looks up the intake handler via `store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
86
- - Publishes `:refresh_started` via the bus.
87
- - Invokes the handler under a 30s `Timeout.timeout` budget.
88
- - On any error: publishes `:refresh_failed`, then re-raises (or wraps in `UsageError`).
89
- - On success: normalizes the return shape, persists via `Application::Writes::Put` with `suppress_events: true`, publishes `:entry_refreshed` (unless the etag is unchanged).
90
- 3. The batch entry point is `Application::Refresh::All.call(ctx, prefix:, zone:)` which lists stale entries via `Application::Reads::Stale`, then runs `Worker#run` per entry, returning a summary envelope `{ refreshed: [...], failed: [...], skipped: [...] }`.
80
+ - Resolves the manifest entry, looks up the intake handler via `@registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
81
+ - Publishes `:refresh_started` with `role:` in the payload.
82
+ - Invokes the handler under a 30s thread-join deadline.
83
+ - On any error: publishes `:refresh_failed`, then re-raises.
84
+ - On success: applies `@authorizer.authorize_write!` and persists via `EnvelopeIO#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
85
+ 3. `ops.refresh_all(prefix:, zone:)` lists stale entries via `Reads::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
91
86
 
92
- ## Infrastructure-side hook dispatch
87
+ ## Hook payload contract
93
88
 
94
- The hook bus needs an `Application::Context` even when fired from inside `Store#initialize` or other infrastructure-side code paths. `Application::Context.system(store)` returns a Context with `role: "human"` and a fresh correlation_id, designed for exactly this case — see `lib/textus/store.rb` (the `:store_loaded` publish), `lib/textus/doctor.rb` (the doctor-check view), and `lib/textus/projection.rb` (the transform_rows view).
89
+ Hooks/intakes/transforms receive the actual `Textus::Store` (the composition root) as `store:`. Every write/refresh event payload carries `role:` directly so hook authors observe the actor without reaching through `store:`.
data/CHANGELOG.md CHANGED
@@ -9,6 +9,492 @@ 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.20.0 — architecture redesign (2026-05-27)
13
+
14
+ **BREAKING (pre-1.0):** Public top-level utility modules removed,
15
+ `Manifest` routing methods extracted into a dedicated resolver,
16
+ `Hooks::Dispatcher`/`Hooks::Registry` collapsed into a single bus, and
17
+ pubsub hook payloads now ship `ctx:` (a `Textus::Hooks::Context`)
18
+ instead of the raw store. External hook files written against the 0.19
19
+ `register(event, name, ...)` API continue to work unchanged; pubsub
20
+ hook bodies must update signatures from `|store:, ...|` to `|ctx:, ...|`
21
+ and use `ctx.put`/`ctx.get`/`ctx.audit`/`ctx.publish_followup` in place
22
+ of direct `store.*` access. RPC events (`transform_rows`, `resolve_intake`,
23
+ `validate`) keep `store:`.
24
+
25
+ ### Added
26
+ - `Textus::Hooks::Context` — narrow handle for user pubsub hooks. Exposes
27
+ `role`, `correlation_id`, `get`, `list`, `deps`, `freshness`, `put`,
28
+ `delete`, `audit`, and `publish_followup`. All writes route back through
29
+ `Operations` so authorization, audit, and validation cannot be bypassed.
30
+
31
+ ### Removed
32
+ - `Textus::Dependencies` — use `Operations#deps`, `#rdeps`, `#published`.
33
+ - `Textus::Refresh` — use `Operations#refresh`. The `normalize_action_result`
34
+ helper is now a private class method on `Application::Refresh::Worker`.
35
+ - `Textus::Hooks::Dispatcher` and `Textus::Hooks::Registry` classes.
36
+
37
+ ### Changed
38
+ - `Textus::Projection` moved to `Textus::Application::Projection`.
39
+ - `Textus::MigrateKeys` moved to `Textus::Application::Tools::MigrateKeys`.
40
+ - `Manifest#resolve`, `#enumerate`, and `#suggestions_for` removed from
41
+ the public `Manifest` API. Use `manifest.resolver.resolve(key)` etc.
42
+ via the new `Manifest::Resolver`. `Manifest` retains the data accessors
43
+ (`entries`, `zones`, `rules`, `permissions`, `validate_key!`).
44
+ - `Store` constructs one `Hooks::Bus`; `Store#registry` removed (use
45
+ `Store#bus`). `Hooks::Builtin.register_all(bus)` and
46
+ `Hooks::Loader.new(bus:)` now take a Bus instead of a Registry.
47
+ `Operations.for` no longer accepts `registry:`. Use cases
48
+ (`Refresh::Worker`, `Refresh::All`) take `bus:`.
49
+ - All pubsub events declare `ctx:` instead of `store:` in their kwargs
50
+ schema. Every `bus.publish` call site passes `ctx: hook_context`.
51
+ `Operations#hook_context` builds the per-`Operations` `Hooks::Context`.
52
+ - Manifest entries gain a required `kind:` field
53
+ (`leaf | nested | derived | intake`). Run
54
+ `textus key normalize --upgrade-manifest` to add it to existing
55
+ manifests — the inference is deterministic and lossless.
56
+ - Internal: `Manifest::Entry` is now an abstract namespace; concrete
57
+ classes are `Entry::Leaf`, `Entry::Nested`, `Entry::Derived`,
58
+ `Entry::Intake`. The fields `projection`, `generator`, `compute`,
59
+ `intake_handler`, `intake_config` are removed from the entry
60
+ interface; `Entry::Derived` carries a typed `source`
61
+ (`Projection` or `External`) and `Entry::Intake` carries `handler`
62
+ / `config`. Use-case code dispatches on entry type rather than
63
+ probing optional fields.
64
+ - `Application::Writes::Build` removed. `Application::Writes::Publish` now
65
+ materializes derived entries (template + projection + external runner)
66
+ AND copies leaf/nested entries to their publish targets in a single pass.
67
+ `Operations#build` is gone; use `Operations#publish` — the `textus build`
68
+ CLI verb is unchanged and produces the same
69
+ `{protocol, built, published_leaves}` JSON shape.
70
+
71
+ ## 0.19.1 — drop textus/2 migration hint (2026-05-27)
72
+
73
+ **BREAKING (pre-1.0):** Users on gem ≤0.10 (manifest protocol `textus/2`)
74
+ no longer receive a stepping-stone hint pointing at 0.11.x. The manifest
75
+ parser and `textus doctor` now emit the generic "unsupported version"
76
+ error. Users on ≤0.10 should install 0.11.x first (still on RubyGems)
77
+ to run the migrator, then upgrade to 0.19.1+.
78
+
79
+ ### Changed
80
+ - `Textus::Manifest` no longer special-cases `textus/2`; `TEXTUS_2_HINT`
81
+ and `version_hint_for` removed.
82
+ - `Doctor::Check::ProtocolVersion` hint/fix text simplified; no longer
83
+ links to the 0.11.x CHANGELOG anchor.
84
+
85
+ ### Removed
86
+ - Two redundant manifest specs (the `Manifest.load` duplicate and the
87
+ `textus/2`-specific hint assertion) collapsed into one generic case.
88
+
89
+ ## 0.19.0 — 2026-05-27
90
+
91
+ ### Breaking
92
+
93
+ - `Application::Context` is now a slim value object (`role`,
94
+ `correlation_id`, `now`, `dry_run`). Migration table:
95
+
96
+ | Was | Now |
97
+ |-----|-----|
98
+ | `Application::Context.new(store:, role:)` | `Operations.for(store, role:)` (common case) or `Application::Context.build(role:)` (pure call state) |
99
+ | `Application::Context.system(store)` | Pass `store` directly to hooks |
100
+ | `ctx.store` / `ctx.manifest` / `ctx.file_store` etc. | Construct use cases with the explicit port kwargs |
101
+ | `ctx.authorize_write!(mentry)` | `Domain::Authorizer.new(manifest:).authorize_write!(mentry, role:)` |
102
+ | `Put.new(ctx:).call(..., suppress_events: true)` | Use `EnvelopeIO#write` directly |
103
+ | `store.role` inside a hook | Read `role:` from the event payload |
104
+
105
+ - `Operations.new(ctx:, manifest:, file_store:, schemas:, audit_log:, bus:, registry:, root:, store:)`
106
+ is the primary constructor. `Operations.for(store, role:)` remains
107
+ a convenience.
108
+
109
+ - `Application::Writes::Put#call` no longer accepts `suppress_events:`.
110
+
111
+ - `Domain::Policy::Predicates::*` moved to `Application::Policy::Predicates::*`.
112
+ `Domain::Policy::Promotion` moved to `Application::Policy::Promotion`.
113
+ `Promotion#evaluate` now takes `entry:, schemas:, manifest:, role:`
114
+ instead of `store:`.
115
+
116
+ - Hooks/intakes/transforms receive the actual `Store` as `store:`
117
+ (previously a Context impersonating one). Event payloads
118
+ (`:entry_put`, `:entry_deleted`, `:entry_renamed`, `:proposal_accepted`,
119
+ `:proposal_rejected`, `:entry_refreshed`, `:refresh_started`,
120
+ `:refresh_failed`, `:refresh_backgrounded`, `:file_published`,
121
+ `:build_completed`) now carry `role:` directly so hooks can observe
122
+ the actor without reaching through the `store:` handle.
123
+
124
+ - `Application::Refresh::All` is a class, not a module function. Callers
125
+ go through `Operations#refresh_all`.
126
+
127
+ ### Added
128
+
129
+ - `Domain::Authorizer` — single source of truth for permission checks.
130
+ - `Application::Policy::Promotion` and `Application::Policy::Predicates::*` —
131
+ the policy evaluator and predicates now live with the Application code
132
+ that loads envelope bytes off disk to evaluate them.
133
+ - `Application::Writes::EnvelopeIO#move` — full move pipeline (replaces
134
+ the in-`Mv` file move + audit).
135
+ - `Application::Writes::EnvelopeIO#read_envelope(key)` — internal
136
+ convenience for callers that need to inspect a pre-move envelope.
137
+
138
+ ### Internal
139
+
140
+ - `Builder::Pipeline` no longer re-enters `Operations.for(store)`; it
141
+ takes reader/lister callables from the caller.
142
+ - CLI verbs construct `Operations`, never `Context` directly.
143
+ - `Operations` no longer memoizes per-use-case factories; only
144
+ `envelope_io`, `refresh_worker`, and `orchestrator` are shared.
145
+ - Wire format `textus/3` unchanged. Audit-log NDJSON unchanged.
146
+
147
+ ### Known follow-ups
148
+
149
+ - CLI verbs `hook run`, `put --fetch`, `hooks list`, and `rule list`
150
+ still reach into `store.manifest` / `store.registry` directly. A
151
+ future release adds `Operations` projections (e.g. `manifest_view`,
152
+ `hooks_view`, `run_intake`) so these verbs route through the
153
+ Operations boundary.
154
+ - `Application::Writes::Delete#call` retains a `suppress_events:` kwarg
155
+ (used internally by `Reject`). A future release either lifts the
156
+ suppression into `EnvelopeIO`-direct usage (matching the `Mv` path)
157
+ or formalizes per-event suppression as part of the public hook API.
158
+
159
+ ## 0.18.1 — 2026-05-27
160
+
161
+ ### Fixed
162
+
163
+ - `Hooks::Dispatcher` no longer uses `Timeout.timeout`, which can interrupt a
164
+ hook mid-syscall or mid-`ensure` and leave Ruby state corrupted. Each
165
+ subscriber now runs in a worker thread joined with a deadline; on overrun
166
+ the thread is killed and the hook is recorded as `timed_out` (distinct
167
+ from `errored`).
168
+
169
+ ### Added
170
+
171
+ - `Hooks::FireReport` — value object returned from `bus.publish`. Lists
172
+ `fired`, `errored`, and `timed_out` subscriber names; exposes `#ok?` and
173
+ `#failures`. Backwards-compatible: callers that ignore the return value
174
+ (the entire current codebase) keep working.
175
+ - `Hooks::Dispatcher#publish` accepts `strict: true`, which re-raises the
176
+ first failure after every subscriber has been attempted. Intended for
177
+ test setups that want loud hooks; default remains `false`.
178
+
179
+ ### Internal
180
+
181
+ - No public API surface changes. CLI behavior, `Operations` methods, wire
182
+ format `textus/3`, and the audit-log NDJSON shape are unchanged. Stores
183
+ written by 0.18.0 round-trip through 0.18.1 byte-for-byte.
184
+
185
+ ## 0.18.0 — 2026-05-27
186
+
187
+ Port extraction finishes the hexagonal trajectory. `Store::Reader` and
188
+ `Store::Writer` were disguised application code under an infra
189
+ namespace; this release replaces them with a true I/O port
190
+ (`Infra::Storage::FileStore`, bytes only) and lifts their orchestration
191
+ into `Application::Writes::EnvelopeIO` and the existing
192
+ `Application::Reads::*`. `Store` becomes a composition root: nothing
193
+ else. Wire format (`textus/3`) and audit log NDJSON line format are
194
+ byte-identical to 0.17.0 — every change is gem-side.
195
+
196
+ ### Breaking (Ruby API)
197
+
198
+ - **`Store::Reader` and `Store::Writer` are deleted.** Both classes
199
+ were doing application work (serialize, UID inject, name-match,
200
+ schema validate, etag negotiate, audit append, event publish) under
201
+ an infra label. Their methods move to flat `Operations` calls:
202
+ ```
203
+ store.reader.get(key) → Textus::Operations#get(key)
204
+ store.reader.read_raw_envelope(key) → Textus::Operations#get(key)
205
+ store.reader.list(prefix:, zone:) → Textus::Operations#list(prefix:, zone:)
206
+ store.reader.where(key) → Textus::Operations#where(key)
207
+ store.reader.uid(key) → Textus::Operations#uid(key)
208
+ store.reader.schema_envelope(key) → Textus::Operations#schema_envelope(key)
209
+ store.reader.published → Textus::Operations#published
210
+ store.reader.stale(...) → Textus::Operations#stale(...)
211
+ store.reader.deps(key) → Textus::Operations#deps(key)
212
+ store.reader.rdeps(key) → Textus::Operations#rdeps(key)
213
+ store.reader.validate_all → Textus::Operations#validate_all
214
+
215
+ store.writer.write_envelope_to_disk → Textus::Operations#put(key, ...)
216
+ store.writer.delete_envelope_from_disk → Textus::Operations#delete(key, ...)
217
+ ```
218
+ - **`Store#schema_for(name)` is deleted.** Schemas live on a dedicated
219
+ cache:
220
+ ```
221
+ store.schema_for(name) → store.schemas.fetch(name)
222
+ ```
223
+ - **Infra/Domain relocations.** Files that were `Store::*` because the
224
+ namespace was a catch-all now live in the layer they belong to:
225
+ ```
226
+ Textus::Store::AuditLog → Textus::Infra::AuditLog
227
+ Textus::Store::Sentinel → Textus::Domain::Sentinel
228
+ Textus::Store::Staleness → Textus::Domain::Staleness
229
+ Textus::Store::Validator → Textus::Application::Reads::Validator
230
+ ```
231
+ - **Write use-case constructors take `envelope_io:`.**
232
+ `Application::Writes::Put.new(ctx:, envelope_io:)` — same for
233
+ `Delete` and `Mv`. External code that constructed write use cases
234
+ directly adds the kwarg.
235
+ - **Note.** Most embedders construct use cases via
236
+ `Textus::Operations.for(store)`. That constructor still works
237
+ without changes — `Operations#for` wires `envelope_io:` from the
238
+ store. Embedders on the recommended path see no breakage.
239
+
240
+ ### Added
241
+
242
+ - **`Textus::Infra::Storage::FileStore`** — pure I/O port. `read`,
243
+ `write`, `delete`, `exists?`, `etag` — bytes in, bytes out. No
244
+ serialization, no schema, no manifest, no events. The seam that
245
+ makes non-file storage backends possible.
246
+ - **`Textus::Schemas`** — eager-loading schema cache. Reads the
247
+ `_schemas/**` zone at boot, exposes `fetch(name)` and `each`.
248
+ Replaces the on-demand `Store#schema_for` lookup.
249
+ - **`Textus::Application::Writes::EnvelopeIO`** — the write pipeline
250
+ collaborator. Serializes the envelope, validates against its
251
+ schema, negotiates etag, writes via `FileStore`, appends to audit,
252
+ publishes the event. The shared orchestration that `Put`,
253
+ `Delete`, and `Mv` previously duplicated through `Store::Writer`.
254
+
255
+ ### Internal
256
+
257
+ - **`Store` is a composition root.** Its responsibilities are
258
+ construction and exposure: `manifest`, `schemas`, `file_store`,
259
+ `audit_log`, `bus`, `registry`, `root`. No `reader`, no `writer`,
260
+ no `schema_for`. Hook loading (`load_hooks`) and operations
261
+ exposure (`operations`) remain — both delegate to dedicated
262
+ collaborators.
263
+ - **Read use cases read from `file_store`/`manifest`/`schemas`
264
+ directly.** `Reads::Get`, `Reads::List`, `Reads::Where`,
265
+ `Reads::Stale`, `Reads::Deps`, etc., no longer route through a
266
+ reader facade. The path is `Operations → use case → ports`.
267
+
268
+ ### Wire format / audit format
269
+
270
+ Unchanged. `textus/3` envelopes written by 0.17.0 round-trip through
271
+ 0.18.0 byte-for-byte; audit log NDJSON lines are bidirectionally
272
+ compatible.
273
+
274
+ ### Migrating from 0.17
275
+
276
+ Mechanical for embedders; transparent for CLI users.
277
+
278
+ ```
279
+ # Reads
280
+ store.reader.get(key) → ops.get(key)
281
+ store.reader.list(prefix: x) → ops.list(prefix: x)
282
+ store.reader.stale(...) → ops.stale(...)
283
+ # (and the rest of the table above)
284
+
285
+ # Writes — recommended path stays the same
286
+ ops.put(key, body: x) # unchanged
287
+
288
+ # Schemas
289
+ store.schema_for(name) → store.schemas.fetch(name)
290
+
291
+ # Renames
292
+ Textus::Store::AuditLog → Textus::Infra::AuditLog
293
+ Textus::Store::Sentinel → Textus::Domain::Sentinel
294
+ Textus::Store::Staleness → Textus::Domain::Staleness
295
+ Textus::Store::Validator → Textus::Application::Reads::Validator
296
+ ```
297
+
298
+ ## 0.17.0 — 2026-05-27
299
+
300
+ API and policy reshape. The public Ruby surface flattens, authorization
301
+ moves from seven duplicated blocks into one helper on `Application::Context`,
302
+ and the only thread-local in the library is gone. Wire format (`textus/3`)
303
+ and CLI JSON output are byte-identical to 0.16.0. Every change is gem-side.
304
+
305
+ ### Breaking (Ruby API)
306
+
307
+ - **`Operations` is flat.** The `Operations#reads`, `Operations#writes`,
308
+ and `Operations#refresh` namespace shells are removed; every use case
309
+ is now a directly-named method on `Operations` itself. Callers that
310
+ typed three levels of indirection plus `.call` switch to a single
311
+ method call:
312
+ ```ruby
313
+ ops.writes.put.call(key, body: x) → ops.put(key, body: x)
314
+ ops.reads.get.call(key) → ops.get(key)
315
+ ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
316
+ ops.refresh.worker.call(key) → ops.refresh(key)
317
+ ops.refresh.all.call(prefix:, …) → ops.refresh_all(prefix:, …)
318
+ ```
319
+ Internal use-case instances are memoized via `||=`. `Operations#with_role`
320
+ returns a fresh `Operations` with no shared memoization.
321
+ - **`Operations::Reads`, `Operations::Writes`, `Operations::Refresh`** —
322
+ the shell classes — are deleted. External code that named them
323
+ directly (rare) must move to the flat methods on `Operations`.
324
+ - **Top-level `Textus.on(event, name) { ... }` is removed.** Hook files
325
+ now wrap registration in a `Textus.hook` block that receives the
326
+ store's registry:
327
+ ```ruby
328
+ # before
329
+ Textus.on(:entry_put, "audit") { |store:, key:, **| ... }
330
+ # after
331
+ Textus.hook do |reg|
332
+ reg.on(:entry_put, "audit") { |store:, key:, **| ... }
333
+ end
334
+ ```
335
+ Multiple `reg.on` lines under one `Textus.hook` block is idiomatic.
336
+ - **`Textus.with_registry` is removed.** Tests instantiate
337
+ `Textus::Hooks::Registry.new` and call `reg.on(...)` directly — no
338
+ `around` block, no thread-local cleanup.
339
+ - **`Textus::Hooks::Loader.current_registry` is removed.** It was the
340
+ thread-local read accessor; nothing replaces it because no thread-
341
+ local remains.
342
+ - **Write use-case constructors lose `bus:`.** `Application::Writes::*`
343
+ classes pull the bus from `@ctx.bus` instead of taking it as a kwarg.
344
+ External code that constructed `Writes::Put.new(ctx:, bus:)` directly
345
+ drops the `bus:` argument.
346
+
347
+ ### Added
348
+
349
+ - **`Application::Context#authorize_write!(mentry)`** — raises
350
+ `WriteForbidden` (with the zone's writers list in `details`) when the
351
+ bound role lacks write permission. Returns `nil` on success. Replaces
352
+ the seven duplicated `unless can_write? ... raise WriteForbidden`
353
+ blocks across `Writes::{Put,Delete,Mv,Accept,Reject,Build,Publish}`.
354
+ - **`Application::Context#authorize_read!(mentry)`** — mirror of
355
+ `authorize_write!`. Raises a new `ReadForbidden` (code `read_forbidden`,
356
+ exit 1, details: `key`, `zone`, `readers`).
357
+ - **`Application::Context#bus`** — returns `store.bus`. Use cases publish
358
+ events through `@ctx.bus`; the prior `@ctx.store.bus` reach-through is
359
+ no longer used in-tree.
360
+ - **`Textus::ReadForbidden`** error class. Symmetric with `WriteForbidden`.
361
+ - **`Textus.hook(&blk)`** — appends the supplied block to a mutex-
362
+ guarded module-level queue. The store-scoped loader drains and invokes
363
+ each block with its registry.
364
+ - **`Textus.drain_hook_blocks`** — public for tests; returns and clears
365
+ the queued blocks under the same mutex.
366
+ - **`Textus::Hooks::Registry#on`** — already the canonical instance API
367
+ since 0.11; explicitly documented as the registration primitive now
368
+ that the top-level shim is gone.
369
+
370
+ ### Internal
371
+
372
+ - **`Application::Writes::Mv`** now authorizes both source and
373
+ destination zones. The prior code authorized only the source; the
374
+ centralized `authorize_write!` made the second call a one-liner and
375
+ the gap obvious.
376
+ - **`Hooks::Builtin.register_all`** takes a `registry:` argument and
377
+ calls `registry.on(...)` directly. No thread-local read.
378
+ - **`Hooks::Loader`** is now a per-store class constructed with
379
+ `registry:`. `#load_dir(path)` walks the directory, `load`s each
380
+ `.rb`, then drains `Textus.drain_hook_blocks` and invokes each with
381
+ the registry. Two threads loading two stores concurrently are safe
382
+ because each `load_dir` drains around its own file walk under the
383
+ module-level mutex.
384
+ - **`Doctor::Check::Hooks`** reads `store.registry` directly; no
385
+ thread-local indirection.
386
+ - **`Store#load_hooks`** is a two-liner: construct a `Loader` with
387
+ `@registry`, call `load_dir` against `.textus/hooks/`.
388
+ - **Reads/refresh paths** use `@ctx.bus` instead of `@ctx.store.bus`.
389
+ Same object; the indirection is gone.
390
+
391
+ ### Migrating from 0.16
392
+
393
+ Mechanical, sed-friendly. The CLI shape is unchanged — only embedders
394
+ and hook authors need to do anything.
395
+
396
+ ```
397
+ # Operations: flat surface
398
+ ops.writes.put.call(key, body: x) → ops.put(key, body: x)
399
+ ops.reads.get.call(key) → ops.get(key)
400
+ ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
401
+ ops.refresh.worker.call(key) → ops.refresh(key) # via Operations#refresh
402
+ ops.refresh.all.call(...) → ops.refresh_all(...)
403
+
404
+ # Hooks: explicit registration
405
+ Textus.on(:entry_put, "x") { |e| ... }
406
+
407
+ Textus.hook do |reg|
408
+ reg.on(:entry_put, "x") { |e| ... }
409
+ end
410
+
411
+ # Tests: no more thread-local scope
412
+ around { |ex| Textus.with_registry(reg) { ex.run } } # delete
413
+ Textus.on(:resolve_intake, :x) { ... } # → reg.on(:resolve_intake, :x) { ... }
414
+ ```
415
+
416
+ If you constructed `Writes::Put` (or any other write use case)
417
+ directly, drop the `bus:` kwarg from the constructor call. If you
418
+ constructed `Hooks::Loader` directly, the new signature is
419
+ `Loader.new(registry:)` and the API is `loader.load_dir(path)`.
420
+
421
+ ### ADRs
422
+
423
+ - [ADR 0010 — Flat Operations API](docs/architecture/decisions/0010-flat-operations-api.md)
424
+ - [ADR 0011 — Authorize-bang in Context](docs/architecture/decisions/0011-authorize-bang-in-context.md)
425
+ - [ADR 0012 — Explicit hook registration](docs/architecture/decisions/0012-explicit-hook-registration.md)
426
+
427
+ ## 0.16.0 — 2026-05-26
428
+
429
+ Type cleanup and infra glue. Wire format (`textus/3`) and CLI JSON output
430
+ are byte-identical to 0.15.0. Every change is gem-side.
431
+
432
+ ### Breaking (Ruby API)
433
+
434
+ - **`Envelope#freshness`** is now a `Textus::Domain::Freshness` value (a
435
+ `Data.define(:stale, :refreshing, :reason, :refresh_error, :checked_at,
436
+ :ttl_remaining_ms)`), not a `Hash`. Field access replaces string-key
437
+ lookup: `env.freshness.stale` (was `env.freshness["stale"]`). The
438
+ field formerly emitted as `"stale_reason"` on the wire is named
439
+ `:reason` on the value object; `Freshness#to_h_for_wire` still emits
440
+ `"stale_reason"`, so JSON output is unchanged. New fields
441
+ (`:checked_at`, `:ttl_remaining_ms`) are gem-side only and not on the
442
+ wire.
443
+ - **`Manifest#resolve(key)`** now returns a `Textus::Manifest::Resolution`
444
+ value (`Data.define(:entry, :path, :remaining)`) instead of an
445
+ `[entry, path, remaining]` tuple. Callers that destructured the array
446
+ must switch to field access: `res = manifest.resolve(key); res.entry`.
447
+ Raises `UnknownKey` on miss (unchanged).
448
+ - **`Textus::Store.mint_uid`** is removed. Use `Textus::Uid.mint`. A
449
+ companion `Textus::Uid.valid?(str)` predicate is added.
450
+ - **`Hooks::Dispatcher.new(audit_log:)`** no longer accepts
451
+ `audit_log:`. The dispatcher is now a pure pub/sub. Hook-error audit
452
+ rows are written by `Textus::Infra::AuditSubscriber`, which `Store`
453
+ attaches at boot. The NDJSON audit line format is unchanged
454
+ byte-for-byte.
455
+
456
+ ### Added
457
+
458
+ - `Textus::Domain::Freshness` — typed envelope-annotation value object.
459
+ - `Textus::Manifest::Resolution` — typed key-resolution value object.
460
+ - `Textus::Uid` — `.mint` / `.valid?` for the 16-hex UID format.
461
+ - `Textus::Infra::AuditSubscriber` — attaches to the event bus and
462
+ writes the `verb: "event_error"` audit row when a user hook raises.
463
+ - `CLI::Verb.command_name "X"` and `CLI::Verb.parent_group Group::Y`
464
+ DSL. Adding a new CLI verb is now a single declaration in the verb's
465
+ own file; the top-level `VERBS` table and group subcommand maps are
466
+ auto-derived from descendants. Help-output ordering is alphabetical
467
+ by command name.
468
+
469
+ ### Changed
470
+
471
+ - `CLI::Group` no longer exposes the `cli_name` writer — use
472
+ `command_name` (the prior `cli_name` reader is removed).
473
+ - `Application::Reads::Get` and `Reads::GetOrRefresh` construct
474
+ `Freshness` values directly; their public signatures are unchanged.
475
+
476
+ ### Deprecated
477
+
478
+ - `Textus::CLI::VERBS` constant. Still resolves (via `const_missing` to
479
+ the auto-derived table) for backward compatibility; will be removed
480
+ in a future minor. Prefer `Textus::CLI.verbs`.
481
+
482
+ ### Notes for embedders
483
+
484
+ - Group subcommand error messages now list subcommands alphabetically
485
+ (e.g., `key requires a subcommand: mv, normalize, uid` rather than
486
+ `mv, uid, normalize`).
487
+ - Lifecycle audit appends for `verb: "put"` / `"delete"` / `"rename"`
488
+ still flow through `Store::Writer` and `Application::Writes::Mv`.
489
+ Centralizing those in a lifecycle subscriber is deferred to 0.18
490
+ port-extraction; it requires event payloads to carry
491
+ `etag_before`/`etag_after`, which they don't yet.
492
+
493
+ ### ADRs
494
+
495
+ - [ADR 0008 — Freshness and Resolution value objects](docs/architecture/decisions/0008-freshness-and-resolution-types.md)
496
+ - [ADR 0009 — AuditSubscriber split from Hooks::Dispatcher](docs/architecture/decisions/0009-audit-subscriber-split.md)
497
+
12
498
  ## 0.15.0 — 2026-05-26
13
499
 
14
500
  ### Breaking