textus 0.14.4 → 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 +378 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +11 -0
  7. data/lib/textus/application/context.rb +25 -7
  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 +38 -33
  13. data/lib/textus/application/reads/get_or_refresh.rb +51 -0
  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 +11 -3
  25. data/lib/textus/application/refresh/worker.rb +27 -20
  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 +8 -1
  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 +4 -1
  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 +40 -35
  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 +84 -17
  105. data/lib/textus/projection.rb +16 -11
  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 +15 -13
  114. data/lib/textus/hooks/dsl.rb +0 -11
  115. data/lib/textus/operations/reads.rb +0 -39
  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: d1e2a12b234bfc677ce44ec7b49b3b04b4ac69abbc15f13496911c82d89549c8
4
- data.tar.gz: 6b83336a05cb3cee509d3e0b90ce2048ed57fdeb5fbc603f22303e10c2d5c27c
3
+ metadata.gz: 3d7915be053bc7e858c821e0e8d8e8cebd3de6acde21dcd8f687ff3fa12db3d1
4
+ data.tar.gz: 792cd40df3e8046c954be84dc32d4e53a2b47cdfd9a21029c74953d39e182a87
5
5
  SHA512:
6
- metadata.gz: f126d898385e0d0236a1818a741923f8ee9f88f1f13cab8ef37bbb810d14145904186084f72d0fbc5a4d6bfdf53b5e7e68699f56db4f9aa02d22772019a11316
7
- data.tar.gz: 054ea4fea78cf26d570319d49f2a0ffbd4023e6355b56b8830650e7d59b1b57ae2de7b158ef0611b465369136e11b292ba9f968113cd425ecd1cfed1bf96633d
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,384 @@ 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
+
325
+ ## 0.15.0 — 2026-05-26
326
+
327
+ ### Breaking
328
+
329
+ - `Application::Reads::Get#call` is now a **pure read**: it returns the
330
+ on-disk envelope annotated with a freshness verdict, and never
331
+ triggers refresh. `Reads::Get.new` no longer accepts `orchestrator:`.
332
+ Callers that relied on refresh-on-read should switch to
333
+ `Application::Reads::GetOrRefresh` (new), accessible via
334
+ `ops.reads.get_or_refresh`. The CLI verb `textus get` is migrated
335
+ internally; users of `textus get` see no behavior change.
336
+ - `Application::Context#bypass_freshness?` and the `bypass_freshness:`
337
+ kwarg on `Context.new` / `Operations.for` are **removed**. They
338
+ shipped in 0.14.4 as a workaround; with `Reads::Get` now pure by
339
+ default, the flag is dead code. Callers passing `bypass_freshness:`
340
+ will see `ArgumentError`.
341
+ - `Projection.new` signature is **breaking**: now
342
+ `Projection.new(reader:, spec:, lister:, transform_resolver:, transform_context:)`.
343
+ `Projection` no longer constructs its own `Operations` chain. Callers
344
+ inject collaborators. `Builder::Pipeline` is migrated internally.
345
+ - Intake handlers now receive `args: { trigger_key:, leaf_segments: }`
346
+ instead of `args: {}`. Handlers that destructure `args` should
347
+ expect the new keys. Handlers that pass `args` through unchanged
348
+ are unaffected.
349
+
350
+ ### Fixed
351
+
352
+ - **Bug 1 / single-flight.** `Refresh::Orchestrator#run_timed` now
353
+ probes the per-leaf lock before forking the detached refresh
354
+ worker. If the lock is held (by a sibling process or earlier fork),
355
+ the orchestrator returns `Outcome::Detached` without spawning a
356
+ redundant worker. Prevents wasted forks when the same key is read
357
+ concurrently across processes.
358
+ - **Bug 2 / leaf-aware intake.** `Refresh::Worker` now keeps the
359
+ `remaining` segments from `Manifest#resolve(key)` and passes them
360
+ to the intake handler as `args: { trigger_key:, leaf_segments: }`.
361
+ Handlers can scope to one leaf instead of re-processing the full
362
+ parent `intake_config` for every leaf refresh.
363
+ - **`textus refresh stale` now exits 0 on success.** Previously the
364
+ verb fell off the end returning `nil`, which propagated up through
365
+ `CLI.run` to `exe/textus:4`'s `exit nil` and raised `TypeError`,
366
+ exit-coding 1 on every successful refresh. Fixed by returning an
367
+ explicit Integer from the verb. The verb return-value contract is
368
+ now codified: every verb's `#call` returns Integer (or `nil` →
369
+ treated as 0); `CLI.run` coerces. (#61)
370
+
371
+ ### Added
372
+
373
+ - `Application::Reads::GetOrRefresh` — explicit composition of pure
374
+ `Reads::Get` with the refresh orchestrator. Use for interactive
375
+ reads that want freshest-obtainable envelopes.
376
+ - `ops.reads.get_or_refresh` accessor.
377
+
378
+ ### Migration
379
+
380
+ If your code (or hook / handler / extension) called:
381
+
382
+ | Old | New |
383
+ |---|---|
384
+ | `ops.reads.get.call(key)` to get the freshest envelope | `ops.reads.get_or_refresh.call(key)` |
385
+ | `ops.reads.get.call(key)` for pure read | unchanged; now also pure semantics |
386
+ | `Operations.for(store, bypass_freshness: true)` | `Operations.for(store)` |
387
+ | `Projection.new(store, spec)` | `Projection.new(reader:, spec:, lister:, transform_resolver:, transform_context:)` — see `builder/pipeline.rb` for canonical wiring |
388
+ | Handler signature `lambda { \|store:, config:, args:\| ... }` with `args == {}` | unchanged — `args` is now populated with `:trigger_key` and `:leaf_segments`; handlers that ignore them keep working |
389
+
12
390
  ## 0.14.4 — 2026-05-26
13
391
 
14
392
  ### Fixed
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
@@ -105,6 +105,17 @@ textus refresh-stale --zone=intake --as=runner # in cron / CI
105
105
 
106
106
  See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
107
107
 
108
+ ### Read vs. refresh
109
+
110
+ There are two read operations, and the difference matters in custom code:
111
+
112
+ | Operation | Triggers refresh? | Use for |
113
+ |-----------|-------------------|---------|
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
+
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
+
108
119
  ## Body content
109
120
 
110
121
  - **Bodies are Markdown.** Headings, lists, code fences — whatever a human or agent finds useful.
@@ -9,13 +9,12 @@ module Textus
9
9
  new(store: store, role: "human")
10
10
  end
11
11
 
12
- def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false, bypass_freshness: false)
12
+ def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
13
13
  @store = store
14
14
  @role = role.to_s
15
15
  @correlation_id = correlation_id || SecureRandom.uuid
16
16
  @clock = clock
17
17
  @dry_run = dry_run
18
- @bypass_freshness = bypass_freshness
19
18
  @now = nil
20
19
  end
21
20
 
@@ -27,10 +26,6 @@ module Textus
27
26
  @dry_run
28
27
  end
29
28
 
30
- def bypass_freshness?
31
- @bypass_freshness
32
- end
33
-
34
29
  def can_write?(zone)
35
30
  store.manifest.permission_for(zone.to_s).allows_write?(role)
36
31
  end
@@ -39,6 +34,30 @@ module Textus
39
34
  store.manifest.permission_for(zone.to_s).allows_read?(role)
40
35
  end
41
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
+
42
61
  def with_role(new_role)
43
62
  self.class.new(
44
63
  store: @store,
@@ -46,7 +65,6 @@ module Textus
46
65
  correlation_id: @correlation_id,
47
66
  clock: @clock,
48
67
  dry_run: @dry_run,
49
- bypass_freshness: @bypass_freshness,
50
68
  )
51
69
  end
52
70
  end
@@ -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.