textus 0.52.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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. 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/{drain,serve,worker,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,Queue,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 the produce pipeline that `drain`/`serve` invoke via the `materialize` job handler; composes `Acquire::Intake` (external pull via handler) with `Produce::Render` (template-driven publish) per entry. Reactive re-produce is enqueued as `materialize` jobs by `Ports::ProduceOnWriteSubscriber` and run by a worker (no in-process thread runner).
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`/`entry_deleted`/`entry_renamed`; enqueues `materialize` jobs onto `Ports::Queue` for reactive re-produce after any write/delete/rename. |
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
 
@@ -192,35 +202,36 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
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
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 (`drain`/`serve` + 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 the `materialize` job handler calls. Both the batch path (`drain`/`serve` seed jobs) and the reactive path (`Ports::ProduceOnWriteSubscriber` enqueues `materialize` jobs on `entry_written`/`entry_deleted`/`entry_renamed`) flow through the queue worker into `converge`.
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
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
 
@@ -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).
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
@@ -1,34 +1,46 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "open3"
2
4
 
3
5
  module Textus
4
- module Read
5
- # For one key, joins every audit-log row with the git commit (sha,
6
- # author, date, subject) that introduced the file state at that audit
7
- # row. Falls back to `git => nil` when not in a git repo or when the
8
- # file is untracked.
9
- class Blame
6
+ module Action
7
+ class Blame < Base
10
8
  extend Textus::Contract::DSL
11
9
 
12
- verb :blame
13
- summary "Annotate audit rows for a key with the git commit that introduced each file state."
10
+ verb :blame
11
+ summary "Annotate audit rows for a key with the git commit that introduced each file state."
14
12
  surfaces :cli
15
- cli "blame"
16
- arg :key, String, required: true, positional: true, description: "entry key to blame"
13
+ cli "blame"
14
+ arg :key, String, required: true, positional: true, description: "entry key to blame"
17
15
  arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
18
16
  view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
19
17
 
20
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
18
+ BURN = :sync
19
+
20
+ def initialize(key:, limit: nil)
21
+ super()
22
+ @key = key
23
+ @limit = limit
24
+ end
25
+
26
+ def call(container:, **)
21
27
  @container = container
22
- @manifest = container.manifest
23
- @root = container.root
28
+ @manifest = container.manifest
29
+ @root = container.root
30
+
31
+ audit_rows = Textus::Action::Audit.new(key: @key, limit: @limit).call(container: container)
32
+ path = resolve_path(@key)
33
+ return audit_rows.map { |row| row.merge("git" => nil) } unless git_tracked?(path)
34
+
35
+ audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"])) }
24
36
  end
25
37
 
26
- def call(key, limit: nil)
27
- audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
28
- path = resolve_path(key)
29
- return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
38
+ def self.new(*args, **kwargs)
39
+ return super(**kwargs) unless args.any?
30
40
 
31
- audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
41
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
42
+ mapped = positional.zip(args).to_h
43
+ super(**mapped.merge(kwargs))
32
44
  end
33
45
 
34
46
  private
@@ -37,9 +49,6 @@ module Textus
37
49
  res = @manifest.resolver.resolve(key)
38
50
  mentry = res.entry
39
51
  path = res.path
40
- # Nested entries resolve to a file under the entry path; leaf entries
41
- # already have a fully-resolved path. Either way `path` is what git
42
- # needs to know about.
43
52
  path || Textus::Key::Path.resolve(@manifest.data, mentry)
44
53
  rescue Textus::Error
45
54
  nil
@@ -60,7 +69,6 @@ module Textus
60
69
  end
61
70
 
62
71
  def git_repo?
63
- # Walk up from store root to find a .git directory.
64
72
  dir = @root
65
73
  loop do
66
74
  return true if File.directory?(File.join(dir, ".git"))
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Boot < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :boot
9
+ summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
10
+ surfaces :cli, :mcp
11
+ arg :lean, :boolean,
12
+ description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
13
+
14
+ BURN = :sync
15
+
16
+ def initialize(lean: nil)
17
+ super()
18
+ @lean = lean
19
+ end
20
+
21
+ def call(container:, **)
22
+ Textus::Boot.build(container: container, lean: !@lean.nil?)
23
+ end
24
+ end
25
+ end
26
+ end