textus 0.51.0 → 0.53.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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
@@ -1,21 +1,26 @@
1
1
  # Textus architecture
2
2
 
3
3
  > **Explanation** · for contributors · **read this first** for orientation before SPEC
4
- > **SSoT for** the Ruby implementation layout (layers, container, ports, read/write/produce paths) · **reviewed** 2026-06 (v0.45)
4
+ > **SSoT for** the Ruby implementation layout (layers, container, ports, dispatch/pipeline paths) · **reviewed** 2026-06 (v0.46)
5
5
 
6
6
  ```mermaid
7
7
  flowchart TD
8
- interface["Interface — CLI verbs · MCP gate (JSON-RPC)"]
9
- application["ApplicationCall · Container · Dispatcher · RoleScope<br/>read/ · write/ · maintenance/ · produce/ use cases · envelope IO"]
10
- domain["DomainPermission · Freshness<br/>Policy (Guard · GuardFactory · BaseGuards · Evaluation · Fetch · Matcher · Predicates)"]
11
- infra["InfrastructureStore · FileStore · Manifest · Schemas<br/>Ports · Hooks · Entry format strategies"]
12
- interface --> application
13
- application --> domain
14
- application --> infra
15
- domain -.->|implemented by| infra
8
+ surfaces["Surfaces — CLI verbs · MCP gate (JSON-RPC) · RoleScope"]
9
+ contract["Contractper-verb DSL (source of truth for public interfaces)"]
10
+ dispatch["DispatchGate · Auth · Ledger · Executor · Actions<br/>planner/ · pipeline/ · runtime/ · catalog/"]
11
+ manifest["Manifestdeclarative config, no IO (policy/, schema/, entry/)"]
12
+ core["Core pure value types (Freshness, Job, Duration, Sentinel)"]
13
+ ports["Ports IO adapters (FileStore, AuditLog, Queue, Publisher…)"]
14
+ step["Step user-injectable wrappers (Fetch, Transform, Validate, Observe)"]
15
+ surfaces --> contract
16
+ contract --> dispatch
17
+ dispatch --> manifest
18
+ dispatch --> core
19
+ dispatch --> ports
20
+ dispatch --> step
16
21
  ```
17
22
 
18
- *Dependency rule: arrows point down.* Domain performs no direct `File`/`Dir`/`Time.now` I/O — all disk and clock access is routed through injected ports; pure path math is allowed. Application imports Domain + Ports. Use cases are plain classes on `(container:, call:)`. Verbs are looked up in the static `Dispatcher::VERBS` table.
23
+ *Dependency rule: inward only.* `dispatch/planner/`, `dispatch/pipeline/`, and `dispatch/runtime/` are private sub-namespaces of `dispatch/` never referenced directly from `surfaces/` or `contract/`. Use cases are plain classes receiving `(container:, call:)`. Verbs are looked up in the static `Dispatcher::VERBS` table.
19
24
 
20
25
  ### What lives in each layer
21
26
 
@@ -44,35 +49,44 @@ generatable) — stay hand-authored, plus commands with no dispatcher verb (`ini
44
49
  `hook`, `mcp serve`, `schema diff/init`). `boot` is auto-generated from its
45
50
  contract. Total reconciliation specs make name/dispatch/facet drift unrepresentable.
46
51
 
47
- **Application**
52
+ **Surfaces**
48
53
 
49
54
  ```
50
- Call (slim Data: role, correlation_id, now,
51
- dry_run — request state only)
52
- Container (single record — wired ports + manifest)
53
- Dispatcher (static VERBS table: verb use-case)
54
- RoleScope (Store#as(role) — forwards verb calls)
55
-
56
- read/{get,list,where,uid,schema_envelope,
57
- deps,rdeps,published,validate_all,boot,doctor,
58
- freshness,audit,blame,rule_explain,rule_list,pulse}.rb
59
- write/{put,key_delete,key_mv,accept,reject,propose}.rb
60
- maintenance/{reconcile,key_mv_prefix,key_delete_prefix,
61
- zone_mv,rule_lint}.rb
62
- produce/{engine,events,render,
63
- acquire/{intake,handler,projection,serializer}}.rb
64
- envelope/io/{reader,writer}.rb (split: parse vs persist)
65
- projection.rb
55
+ CLI verbs: store.<verb>(..., role:)
56
+ store.as(role).<verb>(...)
57
+
58
+ MCP gate: textus mcp serve — same actions, JSON-RPC.
59
+ RoleScope (Store#as(role) — builds Call, forwards to Dispatcher)
60
+ ```
61
+
62
+ **Dispatch (all runtime)**
63
+
64
+ ```
65
+ Gate (thin coordinator: Auth → Ledger → Executor)
66
+ Auth (authorization engine — FLOOR predicates + rule guards)
67
+ Ledger (append event to audit before execution)
68
+ Executor (sync/async routing per action BURN mode)
69
+ Event (Data.define: name, actor, target, payload, actions)
70
+
71
+ actions/{get,list,put,key_delete,key_mv,accept,reject,propose,
72
+ drain,materialize,refresh_data,sweep,observe,
73
+ enqueue,audit,blame,deps,rdeps,published,boot,doctor,
74
+ rule_explain,rule_list,rule_lint,pulse,
75
+ data_mv,key_mv_prefix,key_delete_prefix,
76
+ schema_envelope,where,uid,jobs}.rb
77
+
78
+ planner/{planner,scheduler,seeder}.rb (rules-driven job planning)
79
+ pipeline/{engine,render,acquire/{intake,handler,projection,serializer}}.rb
80
+ runtime/{worker,watch,retention/apply,plan}.rb
81
+ catalog/events.rb (dotted event name constants)
66
82
  ```
67
83
 
68
- **Domain**
84
+ **Core (pure value types)**
69
85
 
70
86
  ```
71
- Permission (write predicate per zone)
72
87
  Freshness::{Verdict,Evaluator}
73
- Action Outcome Sentinel
74
- Policy::{Guard,GuardFactory,BaseGuards,Evaluation,Fetch,Matcher,HandlerAllowlist,
75
- Predicates::{ZoneWritableBy,SchemaValid,AuthorHeld,TargetIsCanon,EtagMatch,FreshWithin}}
88
+ Jobs::Job (immutable job value object)
89
+ Duration Sentinel
76
90
  ```
77
91
 
78
92
  **Infrastructure**
@@ -80,50 +94,45 @@ Policy::{Guard,GuardFactory,BaseGuards,Evaluation,Fetch,Matcher,HandlerAllowlist
80
94
  ```
81
95
  Store (composition root — wires ports,
82
96
  vends a Container + dispatches verbs)
83
- Storage::FileStore (bytes-only port: read/write/delete/
84
- exists?/etag)
97
+ Storage::FileStore (bytes-only port: read/write/delete/exists?/etag)
85
98
  Manifest (Data, Resolver, Policy, Rules)
86
99
  Schemas (eager-load cache)
87
100
  Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
88
- BuildLock,ProduceOnWriteSubscriber,SentinelStore}
89
- Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
90
- Signature,Builtin,ErrorLog}
101
+ BuildLock,Queue,SentinelStore,WatcherLock}
102
+ Step::{EventBus,RegistryStore,Loader,Context,FireReport,
103
+ Signature,Builtin,ErrorLog,Fetch,Transform,Validate,Observe}
91
104
  Entry::{Markdown,Json,Yaml,Text} (format strategies)
105
+ Doctor::Validator (schema + role-authority validation — called by doctor check)
92
106
  ```
93
107
 
94
108
  ## How a verb becomes a method
95
109
 
96
- Each application use case is a plain class under `lib/textus/{read,write,maintenance}/`. The shape is uniform:
110
+ All actions live under `lib/textus/dispatch/actions/`. The shape is uniform:
97
111
 
98
112
  ```ruby
99
113
  module Textus
100
- module Read
101
- class Get
102
- def initialize(container:, call:)
103
- @container = container
104
- @call = call
105
- end
106
-
107
- def call(key)
108
- ...
114
+ module Dispatch
115
+ module Actions
116
+ class Get < Base
117
+ BURN = :sync
118
+
119
+ def call(container:, call:)
120
+ ...
121
+ end
109
122
  end
110
123
  end
111
124
  end
112
125
  end
113
126
  ```
114
127
 
115
- Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get → Textus::Read::Get`, `:put → Textus::Write::Put`, etc. `Store#put` / `Store#get` / `Store#as(role).<verb>(...)` instantiate the use case on `(container:, call:)` and invoke `#call`. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
128
+ Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get → Dispatch::Actions::Get`, `:put → Dispatch::Actions::Put`, etc. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
116
129
 
117
- The instantiate-and-call step itself has one home: `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` (ADR 0026). `RoleScope` builds the `Call` (request state) and delegates the dispatch to `Dispatcher.invoke`; the convention for invoking a uniform-shape use case lives next to the table that maps the verbs, not re-spelled in the caller. `Store`'s own verb loop is separate — it extracts the `role:` keyword and forwards to `as(role)`, a role-selection job distinct from invocation.
130
+ The instantiate-and-call step lives in `Dispatcher.invoke`. `RoleScope` builds the `Call` (request state) and delegates to `Dispatcher.invoke`. Every system interaction flows through `Dispatch::Gate#fire(event)` surfaces, internal cascades (rdeps), and async job workers all use the same path. Gate runs Auth Ledger Executor in sequence.
118
131
 
119
- `boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
120
- are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
121
- `Textus::Doctor` report-building libraries (`build(container:, ...)`). They are
122
- reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
132
+ `boot` and `doctor` are actions like any other reached through `Dispatcher::VERBS`.
123
133
 
124
- Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
134
+ One collaborator lives outside the dispatcher because it's composed by actions, not invoked as a verb:
125
135
 
126
- - `Produce::Engine` — runs `reconcile`'s produce pipeline; composes `Acquire::Intake` (external pull via handler) with `Produce::Render` (template-driven publish) per entry. `AsyncRunner` (nested in `Engine`) enqueues reactive re-produce on `entry_written` events and drains before the process exits.
127
136
  - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
128
137
 
129
138
  ## Container
@@ -133,11 +142,11 @@ Use cases never see the raw `Store`. `Textus::Container` is a single record hold
133
142
  ```ruby
134
143
  Container = Data.define(
135
144
  :manifest, :file_store, :schemas, :root,
136
- :audit_log, :events, :rpc
145
+ :audit_log, :steps, :gate
137
146
  )
138
147
  ```
139
148
 
140
- The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_handler`, `:transform_rows`, `:validate`) receive `caps: <Container>` — field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
149
+ The `Store` builds one `Container` at boot; every action receives it via `(container:, call:)`. Step handlers (Fetch, Transform, Observe) receive `caps: <Container>` — they access `caps.manifest`, `caps.steps`, etc.
141
150
 
142
151
  ## Ports
143
152
 
@@ -150,8 +159,9 @@ Ports are infrastructure adapters with an interface defined by the domain. Each
150
159
  | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
151
160
  | `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel so the next publish can confirm the target is managed. |
152
161
  | `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline. Raises `BuildInProgress` if a build is already running. |
153
- | `Ports::ProduceOnWriteSubscriber` | Pub-sub listener on `entry_written`; enqueues keys into `Produce::Engine::AsyncRunner` for reactive re-produce after any write. |
162
+ | `Ports::Queue` | Persistent job queue used by `drain`/`watch` workers; tracks ready/leased/done/failed jobs and powers async dispatch actions (`materialize`, `observe`). |
154
163
  | `Ports::SentinelStore` | Reads and writes the per-target sentinel file that `Publisher` uses to detect unmanaged overwrites. |
164
+ | `Ports::WatcherLock` | Single-watcher `flock` guard used by `Dispatch::Runtime::Watch` to ensure only one watcher loop is active per store root. |
155
165
 
156
166
  Application use cases access ports only through `Container` fields — never through the raw `Store`.
157
167
 
@@ -186,43 +196,44 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
186
196
 
187
197
  ## Read path (`store.get(key)`)
188
198
 
189
- `Read::Get` is the single public read verb. It is a **pure read** (ADR 0089): it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict — it NEVER ingests and NEVER mutates. The read-through that once refreshed a stale entry in-process (ADR 0062) is removed; quarantine freshness is system-pushed via `reconcile` (scheduled sweep) and `hook run` (event push).
199
+ `Read::Get` is the single public read verb. It is a **pure read** (ADR 0089): it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict — it NEVER ingests and NEVER mutates. The read-through that once refreshed a stale entry in-process (ADR 0062) is removed; quarantine freshness is system-pushed via `drain` (scheduled sweep) and `hook run` (event push).
190
200
 
191
201
  1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
192
202
  2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The verb takes only `key` — there is no `fetch` flag on any surface.
193
- 3. `Read::Get#call(key)` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no `upkeep` rule, the envelope is annotated fresh. A stale entry with `upkeep: { ttl:, action: refresh }` is returned **stale** — the read does not refresh it; the next `reconcile` does.
203
+ 3. `Read::Get#call(key)` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no `upkeep` rule, the envelope is annotated fresh. A stale entry with `upkeep: { ttl:, action: refresh }` is returned **stale** — the read does not refresh it; the next `drain` does.
194
204
 
195
- Because the read is always pure, every caller — interactive reads, dashboards, and the direct in-process callers (accept/reject/publish, materializer, uid, validate_all/validator, schema/tools, hooks/context) — gets the same orchestrator-free, side-effect-free read. The prior read-through path (`get_or_fetch`, then the `fetch:`-flagged `Read::Get`, ADR 0062) and its `Write::FetchOrchestrator` are gone (ADR 0089).
205
+ Because the read is always pure, every caller — interactive reads, dashboards, and the direct in-process callers (accept/reject/publish, materializer, uid, schema/tools, hooks/context) — gets the same orchestrator-free, side-effect-free read. The prior read-through path (`get_or_fetch`, then the `fetch:`-flagged `Read::Get`, ADR 0062) and its `Write::FetchOrchestrator` are gone (ADR 0089).
196
206
 
197
207
  ## Write path (`store.put(key, ...)`)
198
208
 
199
- 1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
200
- 2. `Write::Put#call` validates the key, resolves the manifest entry, builds `GuardFactory.for(:put, key)` and calls `Guard#check!(eval)` (topology is predicate #0, `zone_writable_by`) — raises `WriteForbidden` if the topology gate denies, `GuardFailed` if any other predicate fails.
201
- 3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
202
- 4. Publishes `:entry_written` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
209
+ 1. CLI/MCP surface calls `store.as(role).put(key, meta:, body:, content:, if_etag:)`.
210
+ 2. `Surfaces::RoleScope#dispatch_bound` fires `Gate.fire(Event.new("entry.put", actor: role, ...))`.
211
+ 3. `Dispatch::Gate` runs Auth → Ledger → Executor. `Auth#check_event!` evaluates FLOOR predicates (`lane_writable_by`) plus any rule-declared guards raises `WriteForbidden` / `GuardFailed` on failure.
212
+ 4. `Actions::Put#call` validates the key, resolves the manifest entry, delegates persistence to `Envelope::IO::Writer#put` (serialize schema-validate → etag-check → `FileStore#write` `AuditLog#append`).
213
+ 5. Publishes `:entry_written` via `container.steps` and fires a cascade Gate event for rdep materialization.
203
214
 
204
- `Write::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
215
+ `Actions::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape. All write actions inherit `WriteVerb#run_with_cascade`, which enqueues `materialize` jobs for rdeps after the write completes.
205
216
 
206
- `Write::KeyMv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly no `Put` bypass.
217
+ ## Pipeline path (`drain` + reactive `entry.written`)
207
218
 
208
- ## Produce path (`reconcile` + reactive `entry_written`)
219
+ The pipeline handles two concerns — **acquire** (pull live data via an intake handler) and **render** (template-driven artifact publish) — unified under `Dispatch::Pipeline::Engine`.
209
220
 
210
- The produce pipeline handles two concerns **acquire** (pull live data via an intake handler) and **render** (template-driven artifact publish) unified under `Produce::Engine`.
211
-
212
- `Produce::Engine.converge(container:, call:, keys:)` is the entry point for both `reconcile` (scheduled batch) and the reactive `AsyncRunner` (triggered on `entry_written` by `Ports::ProduceOnWriteSubscriber`).
221
+ `Pipeline::Engine.converge(container:, call:, keys:)` is the entry point `Actions::Materialize` calls. Both the batch path (`drain` seeds jobs via `Planner::Seeder`) and the reactive path (write actions enqueue `materialize` jobs via `WriteVerb#cascade_to_rdeps`) flow through the queue worker into `converge`.
213
222
 
214
223
  For each key, `Engine#produce_one`:
215
224
 
216
- 1. **Acquire phase** — `Produce::Acquire::Intake#run(key)`:
217
- - Resolves the manifest entry; looks up the intake handler via `container.rpc.callable(:resolve_handler, mentry.handler)`.
218
- - Publishes `:entry_fetch_started` via `Produce::Events`.
219
- - Invokes the handler under a timeout deadline.
225
+ 1. **Acquire phase** — `Pipeline::Acquire::Intake#run(key)`:
226
+ - Resolves the manifest entry; looks up the step handler via `container.steps`.
227
+ - Publishes `:entry_fetch_started` via `container.steps`.
228
+ - Invokes the `Step::Fetch` handler under a timeout deadline.
220
229
  - On error: publishes `:entry_fetch_failed`, re-raises.
221
- - On success: normalises the handler result via its own `normalize_action_result` (keyed on the entry's format), checks guard, persists via `Envelope::IO::Writer`, publishes `:entry_fetched` unless the etag is unchanged.
222
- - `Acquire::Handler` resolves and invokes the RPC callable under the timeout deadline. (The sibling **projection** sub-path — `from: project` entries — instead runs `Acquire::Projection`, which renders data files through `Acquire::Serializer::{Json,Yaml,Text}` before persisting.)
223
- 2. **Render phase** — `entry.publish_via(context)` calls `Produce::Render#bytes_for(target:, data:, boot:)` to expand the Mustache template and copy the result to the publish target via `Ports::Publisher`. Returns `nil` if no publish is configured (skipped).
230
+ - On success: normalises the handler result, checks auth, persists via `Envelope::IO::Writer`, publishes `:entry_fetched` unless the etag is unchanged.
231
+ - `Acquire::Handler` resolves and invokes the step under the timeout deadline. (The sibling **projection** sub-path — `from: derive` entries — runs `Acquire::Projection`, which renders data files through `Acquire::Serializer::{Json,Yaml,Text}` before persisting.)
232
+ 2. **Render phase** — `entry.publish_via(context)` calls `Pipeline::Render#bytes_for(target:, data:, boot:)` to expand the Mustache template and copy the result to the publish target via `Ports::Publisher`. Returns `nil` if no publish is configured (skipped).
233
+
234
+ Per-entry failures are published as `:produce_failed` by `Actions::Materialize` after `Engine.converge` returns. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
224
235
 
225
- `AsyncRunner` (nested in `Engine`) enqueues reactive produce when `entry_written` fires, then drains before the process exits via an `at_exit` hook. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
236
+ Reactive produce is enqueued as `materialize` jobs onto `Ports::Queue` when `entry_written`/`entry_deleted`/`entry_renamed` fires; a worker (`drain`/`serve`) runs them through `converge`. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
226
237
 
227
238
  ## Hook payload contract
228
239
 
@@ -257,4 +268,4 @@ Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change
257
268
 
258
269
  `Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027). RPC handlers declare `caps:` (single handler); pub-sub handlers declare `ctx:` (0..N handlers).
259
270
 
260
- The event names, payloads, and per-verb firing order are documented once in [`reference/events.md`](../reference/events.md) (the friendly SSoT); the authoritative source is `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
271
+ The event names, payloads, and per-verb firing order are documented once in [`reference/events.md`](../reference/events.md) (the friendly SSoT); the authoritative source is `lib/textus/step/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`) and `lib/textus/dispatch/catalog/events.rb` (dotted Gate event name constants).
@@ -51,7 +51,7 @@ Tooling around `git blame` or audit logs may filter on owner; the gem itself onl
51
51
 
52
52
  A derived entry declares a `source:` block with a `from:` discriminator (ADR 0093/0094). A `source:` acquires **data** — it never renders; rendering is a publish concern (below). Two `from:` values for derived entries:
53
53
 
54
- **`source: { from: project }`** — textus computes the entry's data on `textus reconcile` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
54
+ **`source: { from: project }`** — textus computes the entry's data on `textus drain` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
55
55
 
56
56
  ```yaml
57
57
  - key: artifacts.derived.people
@@ -69,7 +69,7 @@ A derived entry declares a `source:` block with a `from:` discriminator (ADR 009
69
69
  - { to: docs/people.md, template: people.mustache } # render the data through a template
70
70
  ```
71
71
 
72
- **`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `reconcile` (default: `automation`).
72
+ **`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `converge` (default: `automation`).
73
73
 
74
74
  ```yaml
75
75
  - key: artifacts.derived.skills
@@ -88,11 +88,11 @@ Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../
88
88
 
89
89
  ## Intake and freshness
90
90
 
91
- External inputs land via `:resolve_handler` hooks, not shell commands. Each intake entry declares `source: { from: handler, handler: <name>, ttl: <dur> }`; re-pull is system-pushed via `reconcile` (scheduled sweep) and `hook run` (event push) — a `get` never refreshes (ADR 0089):
91
+ External inputs land via `:resolve_handler` hooks, not shell commands. Each intake entry declares `source: { from: handler, handler: <name>, ttl: <dur> }`; re-pull is system-pushed via `drain` (scheduled sweep) and `hook run` (event push) — a `get` never refreshes (ADR 0089):
92
92
 
93
93
  ```sh
94
94
  textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
95
- textus reconcile --as=automation # re-pulls every intake entry past its source.ttl
95
+ textus drain --as=automation # re-pulls every intake entry past its source.ttl
96
96
  ```
97
97
 
98
98
  The re-pull cadence is the entry's own `source.ttl`. Age-based garbage collection is the orthogonal `retention:` rule slot in the top-level `rules:` block, matched by glob (`{ ttl, action: drop | archive }`); the two compose — re-pull hourly *and* archive at 90 days:
@@ -108,11 +108,11 @@ rules:
108
108
  retention: { ttl: 90d, action: archive }
109
109
  ```
110
110
 
111
- A typical scheduled integration runs `reconcile` on a cron to re-pull every
111
+ A typical scheduled integration runs `drain` on a cron to re-pull every
112
112
  expired feed:
113
113
 
114
114
  ```sh
115
- textus reconcile --as=automation # in cron / CI — re-pulls all stale feeds
115
+ textus drain --as=automation # in cron / CI — re-pulls all stale feeds
116
116
  ```
117
117
 
118
118
  See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
@@ -123,9 +123,9 @@ There is one public read operation, and it is pure (ADR 0089):
123
123
 
124
124
  | Operation | Behaviour | Use for |
125
125
  |-----------|-----------|---------|
126
- | `ops.get` | A pure on-disk read annotated with a freshness verdict — it NEVER ingests, regardless of the entry's `action`. A stale `refresh` entry reads back stale until the next `reconcile`. | every caller — interactive reads, dashboards, scripts, and internal pipelines (materializer, projection, schema tooling, accept/reject/publish, uid, validator) |
126
+ | `ops.get` | A pure on-disk read annotated with a freshness verdict — it NEVER ingests, regardless of the entry's `action`. A stale `refresh` entry reads back stale until the next `drain`. | every caller — interactive reads, dashboards, scripts, and internal pipelines (materializer, projection, schema tooling, accept/reject/publish, uid, validator) |
127
127
 
128
- Refreshing a stale entry is `reconcile`'s job (or a `hook run` event), never a read's — so no caller can accidentally trigger network I/O by reading.
128
+ Refreshing a stale entry is `drain`'s job (or a `hook run` event), never a read's — so no caller can accidentally trigger network I/O by reading.
129
129
 
130
130
  ## Body content
131
131
 
data/exe/textus CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env ruby
2
2
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3
3
  require "textus"
4
- exit Textus::CLI.run(ARGV)
4
+ exit Textus::Surfaces::CLI.run(ARGV)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Accept < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :accept
9
+ summary "apply a queued proposal to its target zone; requires the author capability"
10
+ surfaces :cli, :mcp
11
+ cli "accept"
12
+ arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
13
+
14
+ BURN = :sync
15
+
16
+ def initialize(pending_key:)
17
+ super()
18
+ @pending_key = pending_key
19
+ end
20
+
21
+ def call(container:, call:)
22
+ env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
23
+ proposal = env.meta["proposal"] or raise Textus::ProposalError.new("entry has no proposal block: #{@pending_key}")
24
+ target = proposal["target_key"] or raise Textus::ProposalError.new("proposal missing target_key")
25
+ action = proposal["action"] || "put"
26
+
27
+ case action
28
+ when "put"
29
+ Textus::Action::Put.new(
30
+ key: target,
31
+ meta: env.meta["_meta"] || {},
32
+ body: env.body,
33
+ ).call(container: container, call: call)
34
+ when "delete"
35
+ Textus::Action::KeyDelete.new(key: target).call(container: container, call: call)
36
+ else
37
+ raise Textus::ProposalError.new("unknown action: #{action}")
38
+ end
39
+
40
+ Textus::Action::KeyDelete.new(key: @pending_key).call(container: container, call: call)
41
+
42
+ container.steps.publish(
43
+ :proposal_accepted,
44
+ ctx: Textus::Step::Context.for(container: container, call: call),
45
+ key: @pending_key,
46
+ target_key: target,
47
+ )
48
+
49
+ { "protocol" => Textus::PROTOCOL, "accepted" => @pending_key, "target_key" => target, "action" => action }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Textus
7
+ module Action
8
+ class Audit < Base
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :audit
12
+ summary "Query the audit log with optional filters."
13
+ surfaces :cli
14
+ cli "audit"
15
+ arg :key, String, required: false, description: "filter to rows for this key"
16
+ arg :lane, String, required: false, description: "filter to keys in this lane"
17
+ arg :role, String, required: false, description: "filter to rows written under this role"
18
+ arg :verb, String, required: false, description: "filter to rows for this verb"
19
+ arg :since, String, required: false,
20
+ coerce: ->(s) { Textus::Action::Audit.parse_since(s, now: Time.now) },
21
+ description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
22
+ arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
23
+ arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
24
+ arg :limit, Integer, required: false, description: "maximum number of rows to return"
25
+ view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
26
+
27
+ BURN = :sync
28
+
29
+ def initialize(**kwargs)
30
+ super()
31
+ @query = Query.build(**kwargs.slice(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit))
32
+ end
33
+
34
+ def args
35
+ @query.to_h.compact
36
+ end
37
+
38
+ def call(container:, **)
39
+ @manifest = container.manifest
40
+ @root = container.root
41
+ @log_path = Textus::Layout.audit_log(container.root)
42
+ @audit_log = container.audit_log
43
+
44
+ query = @query
45
+ check_cursor_expiry!(query.seq_since)
46
+
47
+ files = all_log_files
48
+ return [] if files.empty?
49
+
50
+ rows = []
51
+ files.each do |file|
52
+ File.foreach(file) do |line|
53
+ parsed = parse_row(line.chomp)
54
+ next unless parsed
55
+ next unless query.matches?(parsed)
56
+ next if query.lane && !key_in_lane?(parsed["key"], query.lane)
57
+
58
+ rows << parsed
59
+ break if limit_reached?(rows, query)
60
+ end
61
+ break if limit_reached?(rows, query)
62
+ end
63
+
64
+ rows
65
+ end
66
+
67
+ def self.parse_since(str, now: Time.now.utc)
68
+ return nil if str.nil? || str.empty?
69
+ return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
70
+
71
+ match = str.match(/\A(\d+)([smhd])\z/) or return nil
72
+ mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[match[2]]
73
+ now - (match[1].to_i * mult)
74
+ end
75
+
76
+ Query = Data.define(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
77
+ # rubocop:disable Metrics/ParameterLists
78
+ def self.build(key: nil, lane: nil, role: nil, verb: nil,
79
+ since: nil, seq_since: nil, correlation_id: nil, limit: nil)
80
+ new(key:, lane:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
81
+ end
82
+ # rubocop:enable Metrics/ParameterLists
83
+
84
+ def matches?(row)
85
+ return false if key && row["key"] != key
86
+ return false if role && row["role"] != role
87
+ return false if verb && row["verb"] != verb
88
+ return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
89
+ return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
90
+ return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
91
+
92
+ true
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
99
+
100
+ def check_cursor_expiry!(seq_since)
101
+ return unless seq_since
102
+
103
+ log = @audit_log || Textus::Ports::AuditLog.new(@root)
104
+ min = log.min_available_seq
105
+ raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
106
+ end
107
+
108
+ def all_log_files
109
+ rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
110
+ .reject { |path| path.end_with?(".meta.json") }
111
+ .sort_by { |path| -path.scan(/\d+$/).first.to_i }
112
+ active = File.exist?(@log_path) ? [@log_path] : []
113
+ rotated + active
114
+ end
115
+
116
+ def parse_row(line)
117
+ return nil if line.empty?
118
+ return nil unless line.start_with?("{")
119
+
120
+ JSON.parse(line)
121
+ rescue JSON::ParserError
122
+ nil
123
+ end
124
+
125
+ def key_in_lane?(key, lane)
126
+ mentry = @manifest.resolver.resolve(key).entry
127
+ mentry && mentry.lane == lane
128
+ rescue Textus::Error
129
+ false
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ @registry = {}
6
+
7
+ def self.registry = @registry
8
+
9
+ def self.register(klass)
10
+ @registry[klass.name.gsub("::", "/").downcase] = klass
11
+ end
12
+
13
+ def self.fetch(type)
14
+ return @registry[type] if @registry[type]
15
+
16
+ match = @registry.values.find { |k| k.const_defined?(:TYPE, false) && type == k::TYPE }
17
+ raise Textus::UsageError.new("unknown action type: #{type}") unless match
18
+
19
+ @registry[type] = match
20
+ end
21
+
22
+ class Base
23
+ def self.inherited(subclass)
24
+ super
25
+ Textus::Action.register(subclass) if subclass.name
26
+ end
27
+
28
+ def call(**)
29
+ raise NotImplementedError.new("#{self.class}#call")
30
+ end
31
+
32
+ def args
33
+ params = self.class.instance_method(:initialize).parameters
34
+ names = params.select { |t,| %i[key keyreq].include?(t) }.map(&:last)
35
+ names.each_with_object({}) do |name, h|
36
+ val = instance_variable_get(:"@#{name}")
37
+ h[name] = val unless val.nil?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end