textus 0.52.0 → 0.54.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 (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  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 +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  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/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -1,110 +1,146 @@
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 paths) · **reviewed** 2026-06 (v0.54)
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 · MCP · RoleScope"]
9
+ contract["Contractper-verb DSL (source of truth for public interfaces)"]
10
+ gate["Gaterouting (VERB_COMMAND) · Auth"]
11
+ action["Actionsper-verb use cases (action/)"]
12
+ jobs["Jobs Worker · Planner · Materialize · Retention · Sweep"]
13
+ produce["Produce Engine · Render"]
14
+ workflow["Workflow Runner · Registry · Loader · DSL"]
15
+ manifest["Manifest declarative config, no IO (policy/, schema/, entry/)"]
16
+ core["Core — pure value types (Freshness, Retention, Duration, Sentinel)"]
17
+ ports["Ports — IO adapters (FileStore, AuditLog, JobStore, Publisher…)"]
18
+ surfaces --> contract
19
+ contract --> gate
20
+ gate --> action
21
+ action --> jobs
22
+ action --> produce
23
+ produce --> workflow
24
+ action --> manifest
25
+ action --> core
26
+ action --> ports
27
+ jobs --> produce
16
28
  ```
17
29
 
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.
30
+ *Dependency rule: inward only.* Surfaces Contract Gate Actions inner layers. Actions never reference surfaces.
19
31
 
20
32
  ### What lives in each layer
21
33
 
22
- **Interface**
34
+ **Surfaces**
23
35
 
24
36
  ```
25
- CLI verbs: store.<verb>(..., role:)
26
- store.as(role).<verb>(...) # (put/get/fetch/…)
37
+ surfaces/cli/ CLI command generation from contracts
38
+ surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
39
+ surfaces/role_scope.rb
40
+ (Store#as(role)) — holds (container, role, dry_run, correlation_id);
41
+ all verb methods injected via define_method in textus.rb
42
+ ```
43
+
44
+ **Contract**
45
+
46
+ ```
47
+ contract/ Per-verb DSL — verb, summary, surfaces, arg, view.
48
+ Contract::Binder.inputs_from_ordered splits the uniform inputs hash
49
+ into positional/keyword args for every surface.
50
+ ```
51
+
52
+ **Gate + Auth**
53
+
54
+ ```
55
+ gate.rb VERB_COMMAND (verb symbol → Command class)
56
+ Gate#dispatch(cmd) — Auth → action call
57
+ gate/auth.rb Authorization engine — FLOOR predicates + rule guards
58
+ ```
59
+
60
+ **Actions**
61
+
62
+ ```
63
+ action/{get,list,put,key_delete,key_mv,accept,reject,propose,
64
+ drain,enqueue,audit,blame,deps,rdeps,published,boot,doctor,
65
+ rule_explain,rule_list,rule_lint,pulse,
66
+ data_mv,key_mv_prefix,key_delete_prefix,
67
+ schema_envelope,where,uid,jobs}.rb
68
+ ```
69
+
70
+ **Jobs**
27
71
 
28
- MCP gate: textus mcp serve — same use cases, JSON-RPC.
72
+ ```
73
+ jobs/planner.rb Rules-driven job planning — seeds materialize/refresh/sweep jobs
74
+ jobs/worker.rb Queue drain — leases and runs jobs from JobStore
75
+ jobs/materialize.rb Per-key produce job → Produce::Engine.converge
76
+ jobs/refresh.rb Per-key refresh job (retention-rule-triggered)
77
+ jobs/retention.rb Retention policy check
78
+ jobs/sweep.rb Lane sweep job
29
79
  ```
30
80
 
31
- The CLI is a **projection of the per-verb `Contract`** (ADR 0063), the operator
32
- mirror of `MCP::Catalog`. The contract now owns the whole request lifecycle —
33
- `acquire → bind → invoke → render` (ADRs 0066–0068): one `Contract::Binder.bind`
34
- splits the uniform by-name `inputs` hash into the use-case's positional/keyword
35
- args for every surface; per-surface `view`s shape the output (`view` for
36
- MCP/Ruby, `view(:cli)` for the operator envelope); declarative `source:`/
37
- `coerce:`/`cli_stdin` populate inputs from files and stdin; `around:` resources
38
- wrap the single dispatch site (`RoleScope#dispatch_bound`) for stateful verbs;
39
- and `cli_default:` declares a CLI default that diverges from the agent default.
40
- `CLI::Runner` generates a command per `:cli` contract, dispatching `contract.verb`
41
- by construction. Only verbs with genuine *behavior* — `put` (entry persistence),
42
- `get` (UnknownKey + resolver suggestions, CLI-only), and `doctor` (not yet
43
- generatable) — stay hand-authored, plus commands with no dispatcher verb (`init`,
44
- `hook`, `mcp serve`, `schema diff/init`). `boot` is auto-generated from its
45
- contract. Total reconciliation specs make name/dispatch/facet drift unrepresentable.
46
-
47
- **Application**
81
+ **Produce**
48
82
 
49
83
  ```
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
84
+ produce/engine.rb Engine.converge(container:, call:, keys:)
85
+ For each key: looks up a registered workflow and runs it,
86
+ or publishes existing bytes for entries with publish targets.
87
+ produce/render.rb Mustache template expansion for publish targets
66
88
  ```
67
89
 
68
- **Domain**
90
+ **Workflow**
91
+
92
+ ```
93
+ workflow/runner.rb Runs a Definition: build_context → execute_steps → publish
94
+ workflow/registry.rb In-memory list of registered Definitions; Registry#for(key)
95
+ workflow/loader.rb Loads .textus/workflows/*.rb files via Collector DSL
96
+ workflow/dsl.rb Textus.workflow "name" { match "...", step :fetch { ... }, publish }
97
+ workflow/context.rb Context passed to each step (key, entry, config, container, call)
98
+ workflow/pattern.rb Key-matching pattern (glob via File.fnmatch?)
99
+ workflow/errors.rb NotFound, StepFailed
100
+ ```
101
+
102
+ **Core (pure value types)**
69
103
 
70
104
  ```
71
- Permission (write predicate per zone)
72
105
  Freshness::{Verdict,Evaluator}
73
- Action Outcome Sentinel
74
- Policy::{Guard,GuardFactory,BaseGuards,Evaluation,Fetch,Matcher,HandlerAllowlist,
75
- Predicates::{ZoneWritableBy,SchemaValid,AuthorHeld,TargetIsCanon,EtagMatch,FreshWithin}}
106
+ Core::Duration Core::Sentinel
76
107
  ```
77
108
 
78
109
  **Infrastructure**
79
110
 
80
111
  ```
81
- Store (composition root — wires ports,
82
- vends a Container + dispatches verbs)
83
- Storage::FileStore (bytes-only port: read/write/delete/
84
- exists?/etag)
112
+ Store (composition root — wires ports, vends Container)
113
+ Storage::FileStore (bytes-only port: read/write/delete/exists?/etag)
85
114
  Manifest (Data, Resolver, Policy, Rules)
86
115
  Schemas (eager-load cache)
87
- Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
88
- BuildLock,Queue,ProduceOnWriteSubscriber,SentinelStore}
89
- Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
90
- Signature,Builtin,ErrorLog}
116
+ Ports::{AuditLog,JobStore,Publisher,Clock,
117
+ BuildLock,SentinelStore}
91
118
  Entry::{Markdown,Json,Yaml,Text} (format strategies)
92
119
  ```
93
120
 
94
121
  ## How a verb becomes a method
95
122
 
96
- Each application use case is a plain class under `lib/textus/{read,write,maintenance}/`. The shape is uniform:
123
+ All actions live under `lib/textus/action/`. The shape is uniform:
97
124
 
98
125
  ```ruby
99
126
  module Textus
100
- module Read
101
- class Get
102
- def initialize(container:, call:)
103
- @container = container
104
- @call = call
127
+ module Action
128
+ class Get < Base
129
+ extend Textus::Contract::DSL
130
+
131
+ verb :get
132
+ summary "Read an entry by key."
133
+ surfaces :cli, :mcp
134
+ arg :key, String, required: true, positional: true
135
+
136
+ BURN = :sync
137
+
138
+ def initialize(key:)
139
+ super()
140
+ @key = key
105
141
  end
106
142
 
107
- def call(key)
143
+ def call(container:, call:)
108
144
  ...
109
145
  end
110
146
  end
@@ -112,19 +148,7 @@ module Textus
112
148
  end
113
149
  ```
114
150
 
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.
116
-
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.
118
-
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`.
123
-
124
- Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
125
-
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
- - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
151
+ Verbs are looked up in a static frozen table (`Textus::Action::VERBS`) that maps `:get → Action::Get`, `:put → Action::Put`, etc. Adding a new verb is one entry in `VERBS` plus the class — no metaprogramming.
128
152
 
129
153
  ## Container
130
154
 
@@ -133,128 +157,105 @@ Use cases never see the raw `Store`. `Textus::Container` is a single record hold
133
157
  ```ruby
134
158
  Container = Data.define(
135
159
  :manifest, :file_store, :schemas, :root,
136
- :audit_log, :events, :rpc
160
+ :audit_log, :workflows, :gate
137
161
  )
138
162
  ```
139
163
 
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.
164
+ The `Store` builds one `Container` at boot; every action receives it via `(container:, call:)`. Workflow steps receive a `Workflow::Context` (key, entry, config, container, call).
141
165
 
142
166
  ## Ports
143
167
 
144
- Ports are infrastructure adapters with an interface defined by the domain. Each port is independently replaceable — swap the implementation for tests or alternative runtimes without touching application or domain code.
145
-
146
168
  | Class | Role |
147
169
  |---|---|
148
- | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. No knowledge of envelopes or schemas. |
149
- | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
150
- | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
151
- | `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
- | `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. |
154
- | `Ports::SentinelStore` | Reads and writes the per-target sentinel file that `Publisher` uses to detect unmanaged overwrites. |
170
+ | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. |
171
+ | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, rotation. |
172
+ | `Ports::Clock` | Supplies `Time.now` — module-function so tests can swap without DI boilerplate. |
173
+ | `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel. |
174
+ | `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline. |
175
+ | `Ports::JobStore` | Persistent job queue used by `drain` workers; tracks ready/leased/done/failed jobs. |
176
+ | `Ports::SentinelStore` | Reads and writes per-target sentinel files for managed-file detection. |
155
177
 
156
178
  Application use cases access ports only through `Container` fields — never through the raw `Store`.
157
179
 
158
- ### EnvelopeIO
159
-
160
- `Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
180
+ ### Envelope
161
181
 
162
- **Reader** (`lib/textus/envelope/io/reader.rb`) resolves a key through `manifest.resolver`, reads bytes via `FileStore`, delegates parsing to the format strategy (`Entry.for_format`), and returns an `Envelope`. No audit, no events, no permission checks. Also used by `Writer` for the existing-uid lookup on `put`.
182
+ `Envelope::Reader` and `Envelope::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
163
183
 
164
- **Writer** (`lib/textus/envelope/io/writer.rb`) — owns the full write pipeline: serialize schema-validate → etag-check → `FileStore#write` `AuditLog#append`. The class comment states the invariant directly: every public method's final action is `@audit_log.append(...)`. If the audit append fails, the caller sees the underlying error — the byte write already happened, but the pipeline contract treats audit as the commit step. No permission check, no event firing — those stay in the calling use case (`Write::Put`, `Write::Delete`, `Write::Mv`).
184
+ **Reader** (`lib/textus/envelope/reader.rb`) — resolves a key through `manifest.resolver`, reads bytes via `FileStore`, parses via the format strategy, returns an `Envelope`. No audit, no permissions.
165
185
 
166
- The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
186
+ **Writer** (`lib/textus/envelope/writer.rb`) owns the full write pipeline: serialize schema-validate etag-check`FileStore#write``AuditLog#append`. The audit append is the commit step.
167
187
 
168
- Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` (which builds its own `Reader.from`) and `Reader.from(container:)` (ADR 0026). Write use cases call `Writer.from` rather than reconstructing the object graph by hand, so a change to the Writer's dependencies is a one-line edit in one place.
188
+ Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` and `Reader.from(container:)`.
169
189
 
170
190
  ## Manifest carving
171
191
 
172
- Manifest carving means slicing the parsed manifest YAML into four purpose-specific sub-objects. Each consumer sees only the fields it needs; none reach into the full raw document.
173
-
174
- `Manifest` itself is a `Data.define` struct — a composition record with four named members:
192
+ `Manifest` is a `Data.define` struct with four named members:
175
193
 
176
194
  | Member | Class | Responsibility |
177
195
  |---|---|---|
178
- | `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_caps` (role name → capability set). Structural data only — no behaviour beyond accessors and key validation. |
179
- | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
180
- | `policy` | `Manifest::Policy` | Zone/capability authority — `verb_for_zone` (zone-kind → required verb), `roles_with_capability(verb)`, `zone_writers` (derived: roles holding the verb the zone's kind requires), `permission_for`, `declared_kind`, `proposer_role`, `propose_zone_for(role)`. Write authority is derived from capabilities × zone-kind (ADR 0030); no filesystem I/O. `propose_zone_for` returns the single `kind: queue` zone when the role can write it (ADR 0027). |
181
- | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` returns a `RuleSet(fetch, handler_allowlist, guard, retention)` by evaluating all `match:` blocks against the key. |
182
-
183
- Rationale: cleaner test seams — a use case that only needs key resolution constructs a `Manifest::Resolver` from a stub `Data`; one that only needs rule lookup constructs a `Manifest::Rules` directly. No consumer is forced to build the full manifest to exercise one sub-view.
184
-
185
- The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Manifest::Data` constructs `Policy` internally during `initialize`; the others are assembled by the loader and handed in as named arguments.
196
+ | `data` | `Manifest::Data` | Frozen value: raw, root, lanes, entries, audit_config, role_caps. |
197
+ | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Nested entry enumeration and fuzzy-match suggestions. |
198
+ | `policy` | `Manifest::Policy` | Zone/capability authority — `verb_for_lane`, `roles_with_capability`, `propose_lane_for(role)`. |
199
+ | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` `RuleSet(guard, retention, react)`. |
186
200
 
187
201
  ## Read path (`store.get(key)`)
188
202
 
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 `drain` (scheduled sweep) and `hook run` (event push).
203
+ `Action::Get` is a **pure read** it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict. It never ingests and never mutates.
190
204
 
191
- 1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
192
- 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 `drain` does.
205
+ 1. CLI/MCP surface calls `store.as(role).get(key)`.
206
+ 2. `Gate#dispatch` runs Auth → `Action::Get#call`.
207
+ 3. `Get` resolves path via `manifest.resolver`, reads bytes via `file_store`, parses the envelope, annotates `freshness` based on retention-rule TTL (if any).
194
208
 
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).
209
+ Staleness is age-based (retention-rule TTL vs file mtime). A stale entry is returned stale the read does not refresh it; `drain` does.
196
210
 
197
211
  ## Write path (`store.put(key, ...)`)
198
212
 
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:`.
203
-
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.
205
-
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.
213
+ 1. CLI/MCP surface calls `store.as(role).put(key, meta:, body:)`.
214
+ 2. `Gate#dispatch` runs Auth `Action::Put#call`.
215
+ 3. `Put` validates, resolves manifest entry, delegates to `Envelope::Writer#put` (serialize schema-validate etag-check FileStore#write AuditLog#append).
216
+ 4. `WriteVerb#cascade_to_rdeps` enqueues `materialize` jobs for any entries with publish_tree that depend on the written key.
207
217
 
208
- ## Produce path (`drain`/`serve` + reactive `entry_written`)
218
+ `Action::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape.
209
219
 
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`.
220
+ ## Workflow path (`drain` + materialize jobs)
211
221
 
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`.
222
+ The workflow system owns the produce pipeline. `Produce::Engine.converge(container:, call:, keys:)` is the entry point that `Jobs::Materialize` calls.
213
223
 
214
224
  For each key, `Engine#produce_one`:
215
225
 
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.
220
- - 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).
226
+ 1. Looks up a registered workflow via `container.workflows.for(key)` (Pattern matching against the key).
227
+ 2. If a workflow matches: `Workflow::Runner.new(definition, container:, call:).run(key)`.
228
+ - `Runner` builds a `Workflow::Context`, executes each step in sequence, then publishes.
229
+ - **Built-in publish**: writes the step output to the entry via `Envelope::Writer`, then calls `entry.publish_via(pctx)` for any `publish_to` or `publish_tree` targets.
230
+ 3. If no workflow matches but the entry has publish targets: runs `publish_only` — publishes existing store bytes via `entry.publish_via` without re-fetching.
224
231
 
225
- 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.
232
+ **Workflow files** live in `.textus/workflows/*.rb`. The DSL:
226
233
 
227
- ## Hook payload contract
228
-
229
- Pub-sub hooks (`:entry_written`, `:entry_fetched`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
230
-
231
- RPC hooks (`:resolve_handler`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
234
+ ```ruby
235
+ Textus.workflow "my-producer" do
236
+ match "artifacts.derived.*"
237
+ step :fetch do |data, ctx|
238
+ { content: { "key" => ctx.key } }
239
+ end
240
+ publish # uses built-in publish (writes to entry, then to publish targets)
241
+ end
242
+ ```
232
243
 
233
244
  ## Agent surface (boot + pulse + MCP)
234
245
 
235
- Agents and plugins talk to a textus store through three layers:
236
-
237
246
  ```
238
- soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
247
+ agent/plugin ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
239
248
  ```
240
249
 
241
250
  Two transports, one façade:
242
251
 
243
252
  - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
244
- - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, contract_etag) is server-side.
245
-
246
- Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
247
-
248
- The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
249
-
250
- 1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
251
- 2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
252
- 3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
253
-
254
- Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change to the manifest, hooks, or schemas; ADR 0074); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
253
+ - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server. Tools are auto-derived from contracts. Session state (cursor, role, contract_etag) is server-side.
255
254
 
256
- ## Hooks event catalog
255
+ The agent loop:
257
256
 
258
- `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).
257
+ 1. **Session start:** `boot()` contract envelope (lanes, entries, workflows, write_flows, agent_quickstart).
258
+ 2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor, next_due_at}`.
259
+ 3. **On demand:** `get`, `put`, `propose`, `schema_show`, `rule_explain`, `drain`.
259
260
 
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`).
261
+ Contract drift surfaces as `ContractDrift` (contract_etag mismatch a change to manifest/schemas); audit cursor expiry as `CursorExpired`. Both signal "call `boot` again."
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,44 @@
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
+ def initialize(pending_key:)
15
+ super()
16
+ @pending_key = pending_key
17
+ end
18
+
19
+ def call(container:, call:)
20
+ env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
21
+ proposal = env.meta["proposal"] or raise Textus::ProposalError.new("entry has no proposal block: #{@pending_key}")
22
+ target = proposal["target_key"] or raise Textus::ProposalError.new("proposal missing target_key")
23
+ action = proposal["action"] || "put"
24
+
25
+ case action
26
+ when "put"
27
+ Textus::Action::Put.new(
28
+ key: target,
29
+ meta: env.meta["_meta"] || {},
30
+ body: env.body,
31
+ ).call(container: container, call: call)
32
+ when "delete"
33
+ Textus::Action::KeyDelete.new(key: target).call(container: container, call: call)
34
+ else
35
+ raise Textus::ProposalError.new("unknown action: #{action}")
36
+ end
37
+
38
+ Textus::Action::KeyDelete.new(key: @pending_key).call(container: container, call: call)
39
+
40
+ { "protocol" => Textus::PROTOCOL, "accepted" => @pending_key, "target_key" => target, "action" => action }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,131 @@
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
+ def initialize(**kwargs)
28
+ super()
29
+ @query = Query.build(**kwargs.slice(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit))
30
+ end
31
+
32
+ def args
33
+ @query.to_h.compact
34
+ end
35
+
36
+ def call(container:, **)
37
+ @manifest = container.manifest
38
+ @root = container.root
39
+ @log_path = Textus::Layout.audit_log(container.root)
40
+ @audit_log = container.audit_log
41
+
42
+ query = @query
43
+ check_cursor_expiry!(query.seq_since)
44
+
45
+ files = all_log_files
46
+ return [] if files.empty?
47
+
48
+ rows = []
49
+ files.each do |file|
50
+ File.foreach(file) do |line|
51
+ parsed = parse_row(line.chomp)
52
+ next unless parsed
53
+ next unless query.matches?(parsed)
54
+ next if query.lane && !key_in_lane?(parsed["key"], query.lane)
55
+
56
+ rows << parsed
57
+ break if limit_reached?(rows, query)
58
+ end
59
+ break if limit_reached?(rows, query)
60
+ end
61
+
62
+ rows
63
+ end
64
+
65
+ def self.parse_since(str, now: Time.now.utc)
66
+ return nil if str.nil? || str.empty?
67
+ return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
68
+
69
+ match = str.match(/\A(\d+)([smhd])\z/) or return nil
70
+ mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[match[2]]
71
+ now - (match[1].to_i * mult)
72
+ end
73
+
74
+ Query = Data.define(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
75
+ # rubocop:disable Metrics/ParameterLists
76
+ def self.build(key: nil, lane: nil, role: nil, verb: nil,
77
+ since: nil, seq_since: nil, correlation_id: nil, limit: nil)
78
+ new(key:, lane:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
79
+ end
80
+ # rubocop:enable Metrics/ParameterLists
81
+
82
+ def matches?(row)
83
+ return false if key && row["key"] != key
84
+ return false if role && row["role"] != role
85
+ return false if verb && row["verb"] != verb
86
+ return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
87
+ return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
88
+ return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
89
+
90
+ true
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
97
+
98
+ def check_cursor_expiry!(seq_since)
99
+ return unless seq_since
100
+
101
+ log = @audit_log || Textus::Ports::AuditLog.new(@root)
102
+ min = log.min_available_seq
103
+ raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
104
+ end
105
+
106
+ def all_log_files
107
+ rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
108
+ .reject { |path| path.end_with?(".meta.json") }
109
+ .sort_by { |path| -path.scan(/\d+$/).first.to_i }
110
+ active = File.exist?(@log_path) ? [@log_path] : []
111
+ rotated + active
112
+ end
113
+
114
+ def parse_row(line)
115
+ return nil if line.empty?
116
+ return nil unless line.start_with?("{")
117
+
118
+ JSON.parse(line)
119
+ rescue JSON::ParserError
120
+ nil
121
+ end
122
+
123
+ def key_in_lane?(key, lane)
124
+ mentry = @manifest.resolver.resolve(key).entry
125
+ mentry && mentry.lane == lane
126
+ rescue Textus::Error
127
+ false
128
+ end
129
+ end
130
+ end
131
+ end