textus 0.15.0 → 0.18.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -14
  3. data/CHANGELOG.md +313 -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 +24 -0
  8. data/lib/textus/application/reads/audit.rb +1 -1
  9. data/lib/textus/application/reads/blame.rb +3 -1
  10. data/lib/textus/application/reads/deps.rb +1 -1
  11. data/lib/textus/application/reads/freshness.rb +12 -3
  12. data/lib/textus/application/reads/get.rb +32 -8
  13. data/lib/textus/application/reads/get_or_refresh.rb +5 -5
  14. data/lib/textus/application/reads/list.rb +3 -1
  15. data/lib/textus/application/reads/published.rb +1 -1
  16. data/lib/textus/application/reads/rdeps.rb +1 -1
  17. data/lib/textus/application/reads/schema_envelope.rb +3 -1
  18. data/lib/textus/application/reads/stale.rb +1 -1
  19. data/lib/textus/application/reads/uid.rb +1 -1
  20. data/lib/textus/application/reads/validate_all.rb +6 -1
  21. data/lib/textus/application/reads/validator.rb +84 -0
  22. data/lib/textus/application/reads/where.rb +4 -1
  23. data/lib/textus/application/refresh/all.rb +8 -1
  24. data/lib/textus/application/refresh/orchestrator.rb +2 -3
  25. data/lib/textus/application/refresh/worker.rb +18 -15
  26. data/lib/textus/application/writes/accept.rb +12 -12
  27. data/lib/textus/application/writes/build.rb +3 -4
  28. data/lib/textus/application/writes/delete.rb +10 -15
  29. data/lib/textus/application/writes/envelope_io.rb +106 -0
  30. data/lib/textus/application/writes/mv.rb +25 -27
  31. data/lib/textus/application/writes/publish.rb +8 -9
  32. data/lib/textus/application/writes/put.rb +12 -16
  33. data/lib/textus/application/writes/reject.rb +10 -10
  34. data/lib/textus/builder/pipeline.rb +2 -2
  35. data/lib/textus/cli/group/hook.rb +1 -3
  36. data/lib/textus/cli/group/key.rb +1 -4
  37. data/lib/textus/cli/group/refresh.rb +1 -2
  38. data/lib/textus/cli/group/rule.rb +1 -3
  39. data/lib/textus/cli/group/schema.rb +1 -5
  40. data/lib/textus/cli/group.rb +12 -16
  41. data/lib/textus/cli/verb/accept.rb +3 -1
  42. data/lib/textus/cli/verb/audit.rb +3 -1
  43. data/lib/textus/cli/verb/blame.rb +3 -1
  44. data/lib/textus/cli/verb/build.rb +4 -2
  45. data/lib/textus/cli/verb/delete.rb +3 -1
  46. data/lib/textus/cli/verb/deps.rb +3 -1
  47. data/lib/textus/cli/verb/doctor.rb +2 -0
  48. data/lib/textus/cli/verb/freshness.rb +3 -1
  49. data/lib/textus/cli/verb/get.rb +3 -1
  50. data/lib/textus/cli/verb/hook_run.rb +3 -0
  51. data/lib/textus/cli/verb/hooks.rb +3 -0
  52. data/lib/textus/cli/verb/init.rb +2 -0
  53. data/lib/textus/cli/verb/intro.rb +2 -0
  54. data/lib/textus/cli/verb/key_normalize.rb +3 -0
  55. data/lib/textus/cli/verb/list.rb +3 -1
  56. data/lib/textus/cli/verb/mv.rb +4 -1
  57. data/lib/textus/cli/verb/published.rb +3 -1
  58. data/lib/textus/cli/verb/put.rb +3 -1
  59. data/lib/textus/cli/verb/rdeps.rb +3 -1
  60. data/lib/textus/cli/verb/refresh.rb +1 -1
  61. data/lib/textus/cli/verb/refresh_stale.rb +3 -0
  62. data/lib/textus/cli/verb/reject.rb +3 -1
  63. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  64. data/lib/textus/cli/verb/rule_list.rb +3 -0
  65. data/lib/textus/cli/verb/schema.rb +4 -1
  66. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  67. data/lib/textus/cli/verb/schema_init.rb +3 -0
  68. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  69. data/lib/textus/cli/verb/uid.rb +4 -1
  70. data/lib/textus/cli/verb/where.rb +3 -1
  71. data/lib/textus/cli/verb.rb +30 -0
  72. data/lib/textus/cli.rb +18 -27
  73. data/lib/textus/doctor/check/audit_log.rb +1 -1
  74. data/lib/textus/doctor/check/hooks.rb +3 -1
  75. data/lib/textus/doctor/check/intake_registration.rb +3 -3
  76. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  77. data/lib/textus/doctor/check/sentinels.rb +2 -2
  78. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  79. data/lib/textus/domain/freshness/policy.rb +1 -1
  80. data/lib/textus/domain/freshness/verdict.rb +1 -1
  81. data/lib/textus/domain/freshness.rb +40 -0
  82. data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
  83. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  84. data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
  85. data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
  86. data/lib/textus/{store → domain}/staleness.rb +1 -1
  87. data/lib/textus/entry/json.rb +1 -1
  88. data/lib/textus/entry/markdown.rb +1 -1
  89. data/lib/textus/entry/yaml.rb +1 -1
  90. data/lib/textus/envelope.rb +7 -3
  91. data/lib/textus/errors.rb +19 -0
  92. data/lib/textus/hooks/builtin.rb +6 -6
  93. data/lib/textus/hooks/dispatcher.rb +17 -9
  94. data/lib/textus/hooks/loader.rb +20 -17
  95. data/lib/textus/hooks/registry.rb +4 -0
  96. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  97. data/lib/textus/infra/audit_subscriber.rb +43 -0
  98. data/lib/textus/infra/publisher.rb +3 -3
  99. data/lib/textus/infra/storage/file_store.rb +26 -0
  100. data/lib/textus/init.rb +11 -9
  101. data/lib/textus/manifest/resolution.rb +5 -0
  102. data/lib/textus/manifest.rb +4 -3
  103. data/lib/textus/migrate_keys.rb +1 -1
  104. data/lib/textus/operations.rb +83 -16
  105. data/lib/textus/projection.rb +2 -2
  106. data/lib/textus/refresh.rb +1 -1
  107. data/lib/textus/schema/tools.rb +5 -5
  108. data/lib/textus/schemas.rb +46 -0
  109. data/lib/textus/store.rb +12 -49
  110. data/lib/textus/uid.rb +18 -0
  111. data/lib/textus/version.rb +1 -1
  112. data/lib/textus.rb +17 -1
  113. metadata +14 -13
  114. data/lib/textus/hooks/dsl.rb +0 -11
  115. data/lib/textus/operations/reads.rb +0 -56
  116. data/lib/textus/operations/refresh.rb +0 -27
  117. data/lib/textus/operations/writes.rb +0 -21
  118. data/lib/textus/store/reader.rb +0 -69
  119. data/lib/textus/store/validator.rb +0 -82
  120. 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: 3d7915be053bc7e858c821e0e8d8e8cebd3de6acde21dcd8f687ff3fa12db3d1
4
+ data.tar.gz: 792cd40df3e8046c954be84dc32d4e53a2b47cdfd9a21029c74953d39e182a87
5
5
  SHA512:
6
- metadata.gz: 76abb1c22c3f519574dfd310a7ff2162b023bbbc8667b2e4dc2c32edbb94d432bf507620116dbcbc1f7ce3328964515ee8eded62abe1c833249b3b2bb1bd9fce
7
- data.tar.gz: a405b5d81159c8dd0638e9ca6f4fca419358792c7a3a29f64cb0b19386553f37aa091f913c65ad078a039205ad6ac0368af681eba455fcb4c1787bcfb213ac05
6
+ metadata.gz: 457d079d8ebf25922f9389086ec2de99b96808f23fba7f600655fb2ef055a5a987dd1ea63924aaf9904c7f4e600b4aee33a8c2082028bbb9025068aa00de3b0e
7
+ data.tar.gz: a1192a6027b80582723b0cb4679b870ed78101864e22700ba7630308912f46000e774dbd69c589418516047dff2f8cdbf7a26e69e47d3a7ffcef89f917397a23
data/ARCHITECTURE.md CHANGED
@@ -3,16 +3,16 @@
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/…)
9
8
  └──────────────────────┬─────────────────────────────────────┘
10
9
 
11
10
  ┌─ Application ────────▼─────────────────────────────────────┐
12
11
  │ Context (per-request: store, role, correlation, │
13
12
  │ clock, dry_run; can_read?/can_write?; │
13
+ │ authorize_read!/authorize_write!; bus; │
14
14
  │ Context.system(store) for infra path) │
15
- │ Operations (facade with .reads/.writes/.refresh)
15
+ │ Operations (flat facade memoized use cases)
16
16
  │ │
17
17
  │ reads/{get,list,where,uid,schema_envelope,deps,rdeps, │
18
18
  │ published,stale,validate_all,freshness,audit, │
@@ -48,10 +48,10 @@
48
48
  imports. Application imports Domain + Infra (via ports).
49
49
  ```
50
50
 
51
- ## Read path (`ops.reads.get.call(key)`)
51
+ ## Read path (`ops.get(key)`)
52
52
 
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.
53
+ 1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
54
+ 2. `Operations#get` delegates to a memoized `Application::Reads::Get.new(ctx:, orchestrator:)` instance bound to the request context.
55
55
  3. `Reads::Get#call(key)` reads the bare envelope from disk via `@ctx.store.reader.read_raw_envelope(key)`.
56
56
  4. Resolves the manifest rules for the key via `@ctx.store.manifest.rules_for(key)` and extracts the `refresh` policy.
57
57
  5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
@@ -63,10 +63,10 @@
63
63
  - `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_backgrounded`, fork+detach child, return `Outcome::Detached`
64
64
  9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
65
65
 
66
- ## Write path (`ops.writes.put.call(key, ...)`)
66
+ ## Write path (`ops.put(key, ...)`)
67
67
 
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.
68
+ 1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag:, suppress_events:)`.
69
+ 2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@ctx.authorize_write!(mentry)` — raises `WriteForbidden` (carrying the zone's writers list) if denied.
70
70
  3. Delegates raw I/O to `Store::Writer#write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag:)`, which:
71
71
  - Resolves the path via `Manifest#resolve`
72
72
  - Serializes via `Entry.for_format(...).serialize(...)`
@@ -74,13 +74,13 @@
74
74
  - Etag-checks if `if_etag:` provided (raises `EtagMismatch` on conflict)
75
75
  - Writes to disk via `File.binwrite`
76
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:`.
77
+ 4. On success, publishes `:entry_put` via `@ctx.bus`, with `store: @ctx.with_role(@ctx.role)`, `key:`, `envelope:`, `correlation_id:`.
78
78
 
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.
79
+ The same pattern applies to `Writes::{Delete,Mv,Accept,Reject,Build,Publish}`: each takes a `Context`, calls `ctx.authorize_write!` (Mv authorizes both source and destination zones), delegates raw I/O to `Store::Writer` or `Infra::Publisher`, and fires the matching event through `ctx.bus`.
80
80
 
81
- ## Refresh path (`ops.refresh.worker.run(key)`)
81
+ ## Refresh path (`ops.refresh(key)`)
82
82
 
83
- 1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh.worker.run(key)`.
83
+ 1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh(key)`.
84
84
  2. `Refresh::Worker#run(key)`:
85
85
  - Resolves the manifest entry, looks up the intake handler via `store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
86
86
  - Publishes `:refresh_started` via the bus.
data/CHANGELOG.md CHANGED
@@ -9,6 +9,319 @@ 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.18.0 — 2026-05-27
13
+
14
+ Port extraction finishes the hexagonal trajectory. `Store::Reader` and
15
+ `Store::Writer` were disguised application code under an infra
16
+ namespace; this release replaces them with a true I/O port
17
+ (`Infra::Storage::FileStore`, bytes only) and lifts their orchestration
18
+ into `Application::Writes::EnvelopeIO` and the existing
19
+ `Application::Reads::*`. `Store` becomes a composition root: nothing
20
+ else. Wire format (`textus/3`) and audit log NDJSON line format are
21
+ byte-identical to 0.17.0 — every change is gem-side.
22
+
23
+ ### Breaking (Ruby API)
24
+
25
+ - **`Store::Reader` and `Store::Writer` are deleted.** Both classes
26
+ were doing application work (serialize, UID inject, name-match,
27
+ schema validate, etag negotiate, audit append, event publish) under
28
+ an infra label. Their methods move to flat `Operations` calls:
29
+ ```
30
+ store.reader.get(key) → Textus::Operations#get(key)
31
+ store.reader.read_raw_envelope(key) → Textus::Operations#get(key)
32
+ store.reader.list(prefix:, zone:) → Textus::Operations#list(prefix:, zone:)
33
+ store.reader.where(key) → Textus::Operations#where(key)
34
+ store.reader.uid(key) → Textus::Operations#uid(key)
35
+ store.reader.schema_envelope(key) → Textus::Operations#schema_envelope(key)
36
+ store.reader.published → Textus::Operations#published
37
+ store.reader.stale(...) → Textus::Operations#stale(...)
38
+ store.reader.deps(key) → Textus::Operations#deps(key)
39
+ store.reader.rdeps(key) → Textus::Operations#rdeps(key)
40
+ store.reader.validate_all → Textus::Operations#validate_all
41
+
42
+ store.writer.write_envelope_to_disk → Textus::Operations#put(key, ...)
43
+ store.writer.delete_envelope_from_disk → Textus::Operations#delete(key, ...)
44
+ ```
45
+ - **`Store#schema_for(name)` is deleted.** Schemas live on a dedicated
46
+ cache:
47
+ ```
48
+ store.schema_for(name) → store.schemas.fetch(name)
49
+ ```
50
+ - **Infra/Domain relocations.** Files that were `Store::*` because the
51
+ namespace was a catch-all now live in the layer they belong to:
52
+ ```
53
+ Textus::Store::AuditLog → Textus::Infra::AuditLog
54
+ Textus::Store::Sentinel → Textus::Domain::Sentinel
55
+ Textus::Store::Staleness → Textus::Domain::Staleness
56
+ Textus::Store::Validator → Textus::Application::Reads::Validator
57
+ ```
58
+ - **Write use-case constructors take `envelope_io:`.**
59
+ `Application::Writes::Put.new(ctx:, envelope_io:)` — same for
60
+ `Delete` and `Mv`. External code that constructed write use cases
61
+ directly adds the kwarg.
62
+ - **Note.** Most embedders construct use cases via
63
+ `Textus::Operations.for(store)`. That constructor still works
64
+ without changes — `Operations#for` wires `envelope_io:` from the
65
+ store. Embedders on the recommended path see no breakage.
66
+
67
+ ### Added
68
+
69
+ - **`Textus::Infra::Storage::FileStore`** — pure I/O port. `read`,
70
+ `write`, `delete`, `exists?`, `etag` — bytes in, bytes out. No
71
+ serialization, no schema, no manifest, no events. The seam that
72
+ makes non-file storage backends possible.
73
+ - **`Textus::Schemas`** — eager-loading schema cache. Reads the
74
+ `_schemas/**` zone at boot, exposes `fetch(name)` and `each`.
75
+ Replaces the on-demand `Store#schema_for` lookup.
76
+ - **`Textus::Application::Writes::EnvelopeIO`** — the write pipeline
77
+ collaborator. Serializes the envelope, validates against its
78
+ schema, negotiates etag, writes via `FileStore`, appends to audit,
79
+ publishes the event. The shared orchestration that `Put`,
80
+ `Delete`, and `Mv` previously duplicated through `Store::Writer`.
81
+
82
+ ### Internal
83
+
84
+ - **`Store` is a composition root.** Its responsibilities are
85
+ construction and exposure: `manifest`, `schemas`, `file_store`,
86
+ `audit_log`, `bus`, `registry`, `root`. No `reader`, no `writer`,
87
+ no `schema_for`. Hook loading (`load_hooks`) and operations
88
+ exposure (`operations`) remain — both delegate to dedicated
89
+ collaborators.
90
+ - **Read use cases read from `file_store`/`manifest`/`schemas`
91
+ directly.** `Reads::Get`, `Reads::List`, `Reads::Where`,
92
+ `Reads::Stale`, `Reads::Deps`, etc., no longer route through a
93
+ reader facade. The path is `Operations → use case → ports`.
94
+
95
+ ### Wire format / audit format
96
+
97
+ Unchanged. `textus/3` envelopes written by 0.17.0 round-trip through
98
+ 0.18.0 byte-for-byte; audit log NDJSON lines are bidirectionally
99
+ compatible.
100
+
101
+ ### Migrating from 0.17
102
+
103
+ Mechanical for embedders; transparent for CLI users.
104
+
105
+ ```
106
+ # Reads
107
+ store.reader.get(key) → ops.get(key)
108
+ store.reader.list(prefix: x) → ops.list(prefix: x)
109
+ store.reader.stale(...) → ops.stale(...)
110
+ # (and the rest of the table above)
111
+
112
+ # Writes — recommended path stays the same
113
+ ops.put(key, body: x) # unchanged
114
+
115
+ # Schemas
116
+ store.schema_for(name) → store.schemas.fetch(name)
117
+
118
+ # Renames
119
+ Textus::Store::AuditLog → Textus::Infra::AuditLog
120
+ Textus::Store::Sentinel → Textus::Domain::Sentinel
121
+ Textus::Store::Staleness → Textus::Domain::Staleness
122
+ Textus::Store::Validator → Textus::Application::Reads::Validator
123
+ ```
124
+
125
+ ## 0.17.0 — 2026-05-27
126
+
127
+ API and policy reshape. The public Ruby surface flattens, authorization
128
+ moves from seven duplicated blocks into one helper on `Application::Context`,
129
+ and the only thread-local in the library is gone. Wire format (`textus/3`)
130
+ and CLI JSON output are byte-identical to 0.16.0. Every change is gem-side.
131
+
132
+ ### Breaking (Ruby API)
133
+
134
+ - **`Operations` is flat.** The `Operations#reads`, `Operations#writes`,
135
+ and `Operations#refresh` namespace shells are removed; every use case
136
+ is now a directly-named method on `Operations` itself. Callers that
137
+ typed three levels of indirection plus `.call` switch to a single
138
+ method call:
139
+ ```ruby
140
+ ops.writes.put.call(key, body: x) → ops.put(key, body: x)
141
+ ops.reads.get.call(key) → ops.get(key)
142
+ ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
143
+ ops.refresh.worker.call(key) → ops.refresh(key)
144
+ ops.refresh.all.call(prefix:, …) → ops.refresh_all(prefix:, …)
145
+ ```
146
+ Internal use-case instances are memoized via `||=`. `Operations#with_role`
147
+ returns a fresh `Operations` with no shared memoization.
148
+ - **`Operations::Reads`, `Operations::Writes`, `Operations::Refresh`** —
149
+ the shell classes — are deleted. External code that named them
150
+ directly (rare) must move to the flat methods on `Operations`.
151
+ - **Top-level `Textus.on(event, name) { ... }` is removed.** Hook files
152
+ now wrap registration in a `Textus.hook` block that receives the
153
+ store's registry:
154
+ ```ruby
155
+ # before
156
+ Textus.on(:entry_put, "audit") { |store:, key:, **| ... }
157
+ # after
158
+ Textus.hook do |reg|
159
+ reg.on(:entry_put, "audit") { |store:, key:, **| ... }
160
+ end
161
+ ```
162
+ Multiple `reg.on` lines under one `Textus.hook` block is idiomatic.
163
+ - **`Textus.with_registry` is removed.** Tests instantiate
164
+ `Textus::Hooks::Registry.new` and call `reg.on(...)` directly — no
165
+ `around` block, no thread-local cleanup.
166
+ - **`Textus::Hooks::Loader.current_registry` is removed.** It was the
167
+ thread-local read accessor; nothing replaces it because no thread-
168
+ local remains.
169
+ - **Write use-case constructors lose `bus:`.** `Application::Writes::*`
170
+ classes pull the bus from `@ctx.bus` instead of taking it as a kwarg.
171
+ External code that constructed `Writes::Put.new(ctx:, bus:)` directly
172
+ drops the `bus:` argument.
173
+
174
+ ### Added
175
+
176
+ - **`Application::Context#authorize_write!(mentry)`** — raises
177
+ `WriteForbidden` (with the zone's writers list in `details`) when the
178
+ bound role lacks write permission. Returns `nil` on success. Replaces
179
+ the seven duplicated `unless can_write? ... raise WriteForbidden`
180
+ blocks across `Writes::{Put,Delete,Mv,Accept,Reject,Build,Publish}`.
181
+ - **`Application::Context#authorize_read!(mentry)`** — mirror of
182
+ `authorize_write!`. Raises a new `ReadForbidden` (code `read_forbidden`,
183
+ exit 1, details: `key`, `zone`, `readers`).
184
+ - **`Application::Context#bus`** — returns `store.bus`. Use cases publish
185
+ events through `@ctx.bus`; the prior `@ctx.store.bus` reach-through is
186
+ no longer used in-tree.
187
+ - **`Textus::ReadForbidden`** error class. Symmetric with `WriteForbidden`.
188
+ - **`Textus.hook(&blk)`** — appends the supplied block to a mutex-
189
+ guarded module-level queue. The store-scoped loader drains and invokes
190
+ each block with its registry.
191
+ - **`Textus.drain_hook_blocks`** — public for tests; returns and clears
192
+ the queued blocks under the same mutex.
193
+ - **`Textus::Hooks::Registry#on`** — already the canonical instance API
194
+ since 0.11; explicitly documented as the registration primitive now
195
+ that the top-level shim is gone.
196
+
197
+ ### Internal
198
+
199
+ - **`Application::Writes::Mv`** now authorizes both source and
200
+ destination zones. The prior code authorized only the source; the
201
+ centralized `authorize_write!` made the second call a one-liner and
202
+ the gap obvious.
203
+ - **`Hooks::Builtin.register_all`** takes a `registry:` argument and
204
+ calls `registry.on(...)` directly. No thread-local read.
205
+ - **`Hooks::Loader`** is now a per-store class constructed with
206
+ `registry:`. `#load_dir(path)` walks the directory, `load`s each
207
+ `.rb`, then drains `Textus.drain_hook_blocks` and invokes each with
208
+ the registry. Two threads loading two stores concurrently are safe
209
+ because each `load_dir` drains around its own file walk under the
210
+ module-level mutex.
211
+ - **`Doctor::Check::Hooks`** reads `store.registry` directly; no
212
+ thread-local indirection.
213
+ - **`Store#load_hooks`** is a two-liner: construct a `Loader` with
214
+ `@registry`, call `load_dir` against `.textus/hooks/`.
215
+ - **Reads/refresh paths** use `@ctx.bus` instead of `@ctx.store.bus`.
216
+ Same object; the indirection is gone.
217
+
218
+ ### Migrating from 0.16
219
+
220
+ Mechanical, sed-friendly. The CLI shape is unchanged — only embedders
221
+ and hook authors need to do anything.
222
+
223
+ ```
224
+ # Operations: flat surface
225
+ ops.writes.put.call(key, body: x) → ops.put(key, body: x)
226
+ ops.reads.get.call(key) → ops.get(key)
227
+ ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
228
+ ops.refresh.worker.call(key) → ops.refresh(key) # via Operations#refresh
229
+ ops.refresh.all.call(...) → ops.refresh_all(...)
230
+
231
+ # Hooks: explicit registration
232
+ Textus.on(:entry_put, "x") { |e| ... }
233
+
234
+ Textus.hook do |reg|
235
+ reg.on(:entry_put, "x") { |e| ... }
236
+ end
237
+
238
+ # Tests: no more thread-local scope
239
+ around { |ex| Textus.with_registry(reg) { ex.run } } # delete
240
+ Textus.on(:resolve_intake, :x) { ... } # → reg.on(:resolve_intake, :x) { ... }
241
+ ```
242
+
243
+ If you constructed `Writes::Put` (or any other write use case)
244
+ directly, drop the `bus:` kwarg from the constructor call. If you
245
+ constructed `Hooks::Loader` directly, the new signature is
246
+ `Loader.new(registry:)` and the API is `loader.load_dir(path)`.
247
+
248
+ ### ADRs
249
+
250
+ - [ADR 0010 — Flat Operations API](docs/architecture/decisions/0010-flat-operations-api.md)
251
+ - [ADR 0011 — Authorize-bang in Context](docs/architecture/decisions/0011-authorize-bang-in-context.md)
252
+ - [ADR 0012 — Explicit hook registration](docs/architecture/decisions/0012-explicit-hook-registration.md)
253
+
254
+ ## 0.16.0 — 2026-05-26
255
+
256
+ Type cleanup and infra glue. Wire format (`textus/3`) and CLI JSON output
257
+ are byte-identical to 0.15.0. Every change is gem-side.
258
+
259
+ ### Breaking (Ruby API)
260
+
261
+ - **`Envelope#freshness`** is now a `Textus::Domain::Freshness` value (a
262
+ `Data.define(:stale, :refreshing, :reason, :refresh_error, :checked_at,
263
+ :ttl_remaining_ms)`), not a `Hash`. Field access replaces string-key
264
+ lookup: `env.freshness.stale` (was `env.freshness["stale"]`). The
265
+ field formerly emitted as `"stale_reason"` on the wire is named
266
+ `:reason` on the value object; `Freshness#to_h_for_wire` still emits
267
+ `"stale_reason"`, so JSON output is unchanged. New fields
268
+ (`:checked_at`, `:ttl_remaining_ms`) are gem-side only and not on the
269
+ wire.
270
+ - **`Manifest#resolve(key)`** now returns a `Textus::Manifest::Resolution`
271
+ value (`Data.define(:entry, :path, :remaining)`) instead of an
272
+ `[entry, path, remaining]` tuple. Callers that destructured the array
273
+ must switch to field access: `res = manifest.resolve(key); res.entry`.
274
+ Raises `UnknownKey` on miss (unchanged).
275
+ - **`Textus::Store.mint_uid`** is removed. Use `Textus::Uid.mint`. A
276
+ companion `Textus::Uid.valid?(str)` predicate is added.
277
+ - **`Hooks::Dispatcher.new(audit_log:)`** no longer accepts
278
+ `audit_log:`. The dispatcher is now a pure pub/sub. Hook-error audit
279
+ rows are written by `Textus::Infra::AuditSubscriber`, which `Store`
280
+ attaches at boot. The NDJSON audit line format is unchanged
281
+ byte-for-byte.
282
+
283
+ ### Added
284
+
285
+ - `Textus::Domain::Freshness` — typed envelope-annotation value object.
286
+ - `Textus::Manifest::Resolution` — typed key-resolution value object.
287
+ - `Textus::Uid` — `.mint` / `.valid?` for the 16-hex UID format.
288
+ - `Textus::Infra::AuditSubscriber` — attaches to the event bus and
289
+ writes the `verb: "event_error"` audit row when a user hook raises.
290
+ - `CLI::Verb.command_name "X"` and `CLI::Verb.parent_group Group::Y`
291
+ DSL. Adding a new CLI verb is now a single declaration in the verb's
292
+ own file; the top-level `VERBS` table and group subcommand maps are
293
+ auto-derived from descendants. Help-output ordering is alphabetical
294
+ by command name.
295
+
296
+ ### Changed
297
+
298
+ - `CLI::Group` no longer exposes the `cli_name` writer — use
299
+ `command_name` (the prior `cli_name` reader is removed).
300
+ - `Application::Reads::Get` and `Reads::GetOrRefresh` construct
301
+ `Freshness` values directly; their public signatures are unchanged.
302
+
303
+ ### Deprecated
304
+
305
+ - `Textus::CLI::VERBS` constant. Still resolves (via `const_missing` to
306
+ the auto-derived table) for backward compatibility; will be removed
307
+ in a future minor. Prefer `Textus::CLI.verbs`.
308
+
309
+ ### Notes for embedders
310
+
311
+ - Group subcommand error messages now list subcommands alphabetically
312
+ (e.g., `key requires a subcommand: mv, normalize, uid` rather than
313
+ `mv, uid, normalize`).
314
+ - Lifecycle audit appends for `verb: "put"` / `"delete"` / `"rename"`
315
+ still flow through `Store::Writer` and `Application::Writes::Mv`.
316
+ Centralizing those in a lifecycle subscriber is deferred to 0.18
317
+ port-extraction; it requires event payloads to carry
318
+ `etag_before`/`etag_after`, which they don't yet.
319
+
320
+ ### ADRs
321
+
322
+ - [ADR 0008 — Freshness and Resolution value objects](docs/architecture/decisions/0008-freshness-and-resolution-types.md)
323
+ - [ADR 0009 — AuditSubscriber split from Hooks::Dispatcher](docs/architecture/decisions/0009-audit-subscriber-split.md)
324
+
12
325
  ## 0.15.0 — 2026-05-26
13
326
 
14
327
  ### Breaking
data/README.md CHANGED
@@ -14,7 +14,7 @@ Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC
14
14
  Two versions, deliberately independent:
15
15
 
16
16
  - **Protocol wire string:** `textus/3`. Breaking changes require `textus/4`.
17
- - **Gem version:** semver, currently `0.14.0`. Decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
17
+ - **Gem version:** semver, currently `0.18.0`. 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
 
@@ -119,18 +119,22 @@ textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectorie
119
119
 
120
120
  ```ruby
121
121
  # Inside .textus/hooks/local_file.rb
122
- Textus.on(:resolve_intake, :local_file) do |config:, args:, **|
123
- path = config["path"] or raise "local-file requires intake.config.path"
124
- {
125
- _meta: { "last_refreshed_at" => Time.now.utc.iso8601, "source_path" => path },
126
- body: File.read(File.expand_path(path)),
127
- }
122
+ Textus.hook do |reg|
123
+ reg.on(:resolve_intake, :local_file) do |config:, args:, **|
124
+ path = config["path"] or raise "local-file requires intake.config.path"
125
+ {
126
+ _meta: { "last_refreshed_at" => Time.now.utc.iso8601, "source_path" => path },
127
+ body: File.read(File.expand_path(path)),
128
+ }
129
+ end
128
130
  end
129
131
  ```
130
132
 
131
133
  ```ruby
132
- Textus.on(:transform_rows, :rank_by_recency) do |rows:, **|
133
- rows.sort_by { |r| r["updated_at"].to_s }.reverse
134
+ Textus.hook do |reg|
135
+ reg.on(:transform_rows, :rank_by_recency) do |rows:, **|
136
+ rows.sort_by { |r| r["updated_at"].to_s }.reverse
137
+ end
134
138
  end
135
139
  ```
136
140
 
data/SPEC.md CHANGED
@@ -450,7 +450,7 @@ Row transforms are RPC hooks on the `:transform_rows` event. See §5.10.
450
450
 
451
451
  ### 5.10 Hooks
452
452
 
453
- textus has a single hook registration verb: `Textus.on(event, name, **opts) { ... }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path.
453
+ textus has a single hook registration verb: `Textus.hook { |reg| reg.on(event, name, **opts) { ... } }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path; the store-scoped loader drains the queued blocks and invokes each with its own registry.
454
454
 
455
455
  The subdirectory layout under `hooks/` is organizational only; the registered event and name come from the DSL call, not the file path.
456
456
 
@@ -458,14 +458,16 @@ The subdirectory layout under `hooks/` is organizational only; the registered ev
458
458
 
459
459
  ```ruby
460
460
  # Canonical form — works for every event:
461
- Textus.on(:resolve_intake, :my_source) { |config:, args:, **| … }
462
- Textus.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
463
- Textus.on(:validate, :storage_writable) { |store:| … }
464
- Textus.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| … }
465
- Textus.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
461
+ Textus.hook do |reg|
462
+ reg.on(:resolve_intake, :my_source) { |config:, args:, **| … }
463
+ reg.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
464
+ reg.on(:validate, :storage_writable) { |store:| … }
465
+ reg.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| }
466
+ reg.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
467
+ end
466
468
  ```
467
469
 
468
- `Textus.on` is the sole entry point; there is no separate `Textus.hook` primitive.
470
+ `Textus.hook` is the sole entry point. The block receives the store's `Hooks::Registry`; `reg.on` is the only registration primitive.
469
471
 
470
472
  #### Event table
471
473
 
@@ -821,7 +823,7 @@ Textus internals are organized into four layers. The dependency rule is one-way
821
823
 
822
824
  The `lib/textus/store/`, `lib/textus/manifest/`, `lib/textus/hooks/` namespaces are infrastructure adapters that predate this split and remain at their existing paths for backward-compat with the plugin DSL.
823
825
 
824
- Plugin authors interact only with the Hook DSL (`Textus.on(:resolve_intake, ...)`, `Textus.on(:entry_refreshed, ...)`, etc.) and the manifest YAML schema. The layering is internal and may evolve.
826
+ Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:resolve_intake, ...) }`, `reg.on(:entry_refreshed, ...)`, etc.) and the manifest YAML schema. The layering is internal and may evolve.
825
827
 
826
828
  Both read and write paths flow through the application layer:
827
829
 
@@ -830,8 +832,9 @@ Both read and write paths flow through the application layer:
830
832
  - `Application::Context` is the universal request object: it carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. Use cases never thread these as separate kwargs.
831
833
  - `Textus::Operations` is the factory CLI verbs (and future MCP server / HTTP shim)
832
834
  use to construct Contexts and use cases. `Operations.for(store, role:)` returns
833
- a memoized facade with `.reads`, `.writes`, and `.refresh` namespaces mirroring the
834
- files under `lib/textus/application/{reads,writes,refresh}/`.
835
+ a flat facade exposing one method per use case (`#put`, `#get`, `#refresh`, …);
836
+ internal use-case instances are memoized via `||=` and live under
837
+ `lib/textus/application/{reads,writes,refresh}/`.
835
838
 
836
839
  See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
837
840
 
data/docs/conventions.md CHANGED
@@ -111,8 +111,8 @@ There are two read operations, and the difference matters in custom code:
111
111
 
112
112
  | Operation | Triggers refresh? | Use for |
113
113
  |-----------|-------------------|---------|
114
- | `ops.reads.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
115
- | `ops.reads.get_or_refresh` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
114
+ | `ops.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
115
+ | `ops.get_or_refresh` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
116
116
 
117
117
  Build always uses the pure path; injecting refresh into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_refresh` only when you genuinely want side effects on read.
118
118
 
@@ -34,6 +34,30 @@ module Textus
34
34
  store.manifest.permission_for(zone.to_s).allows_read?(role)
35
35
  end
36
36
 
37
+ def bus
38
+ @store.bus
39
+ end
40
+
41
+ def manifest = @store.manifest
42
+ def schemas = @store.schemas
43
+ def file_store = @store.file_store
44
+ def audit_log = @store.audit_log
45
+
46
+ def authorize_write!(mentry)
47
+ return if can_write?(mentry.zone)
48
+
49
+ writers = @store.manifest.zone_writers(mentry.zone)
50
+ raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
51
+ end
52
+
53
+ def authorize_read!(mentry)
54
+ return if can_read?(mentry.zone)
55
+
56
+ readers = @store.manifest.zone_readers[mentry.zone]
57
+ readers = nil if readers == :all
58
+ raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
59
+ end
60
+
37
61
  def with_role(new_role)
38
62
  self.class.new(
39
63
  store: @store,
@@ -58,7 +58,7 @@ module Textus
58
58
  end
59
59
 
60
60
  def key_in_zone?(key, zone)
61
- mentry, = @ctx.store.manifest.resolve(key)
61
+ mentry = @ctx.store.manifest.resolve(key).entry
62
62
  mentry && mentry.zone == zone
63
63
  rescue Textus::Error
64
64
  false
@@ -23,7 +23,9 @@ module Textus
23
23
  private
24
24
 
25
25
  def resolve_path(key)
26
- mentry, path, = @ctx.store.manifest.resolve(key)
26
+ res = @ctx.store.manifest.resolve(key)
27
+ mentry = res.entry
28
+ path = res.path
27
29
  # Nested entries resolve to a file under the entry path; leaf entries
28
30
  # already have a fully-resolved path. Either way `path` is what git
29
31
  # needs to know about.
@@ -7,7 +7,7 @@ module Textus
7
7
  end
8
8
 
9
9
  def call(key)
10
- @ctx.store.reader.deps(key)
10
+ Dependencies.deps_of(@ctx.manifest, key)
11
11
  end
12
12
  end
13
13
  end
@@ -15,7 +15,7 @@ module Textus
15
15
 
16
16
  def call(prefix: nil, zone: nil)
17
17
  rows = []
18
- @ctx.store.manifest.entries.each do |mentry|
18
+ @ctx.manifest.entries.each do |mentry|
19
19
  next if prefix && !mentry.key.start_with?(prefix)
20
20
  next if zone && mentry.zone != zone
21
21
 
@@ -27,7 +27,7 @@ module Textus
27
27
  private
28
28
 
29
29
  def row_for(mentry)
30
- set = @ctx.store.manifest.rules_for(mentry.key)
30
+ set = @ctx.manifest.rules_for(mentry.key)
31
31
  refresh = set.refresh
32
32
  envelope = safe_get(mentry.key)
33
33
  last = envelope&.meta&.dig("last_refreshed_at")
@@ -61,7 +61,16 @@ module Textus
61
61
  # Returns the raw envelope or nil. Nested entries (mentry.key is a
62
62
  # prefix, not a leaf) and missing files both resolve to nil.
63
63
  def safe_get(key)
64
- @ctx.store.reader.read_raw_envelope(key)
64
+ res = @ctx.manifest.resolve(key)
65
+ return nil unless @ctx.file_store.exists?(res.path)
66
+
67
+ raw = @ctx.file_store.read(res.path)
68
+ parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
69
+ Envelope.build(
70
+ key: key, mentry: res.entry, path: res.path,
71
+ meta: parsed["_meta"], body: parsed["body"],
72
+ etag: Etag.for_bytes(raw), content: parsed["content"]
73
+ )
65
74
  rescue Textus::Error
66
75
  nil
67
76
  end