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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class KeyDeletePrefix < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_delete_prefix
9
+ summary "Bulk-delete every leaf key under prefix."
10
+ surfaces :cli, :mcp
11
+ cli "key delete-prefix"
12
+ arg :prefix, String, required: true, positional: true,
13
+ description: "every leaf key under this dotted prefix is deleted"
14
+ arg :dry_run, :boolean, default: false,
15
+ description: "when true, returns the keys that would be deleted without deleting them; " \
16
+ "defaults to false, so omitting it deletes immediately"
17
+ view { |v, _i| v.to_h }
18
+
19
+ def initialize(prefix:, dry_run: false)
20
+ super()
21
+ @prefix = prefix
22
+ @dry_run = dry_run
23
+ end
24
+
25
+ def call(container:, call:)
26
+ raise UsageError.new("prefix required") if @prefix.nil? || @prefix.empty?
27
+
28
+ leaves = Textus::Action::List.new(prefix: @prefix).call(container: container)
29
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
30
+
31
+ warnings = leaves.empty? ? ["no keys under #{@prefix}"] : []
32
+ steps = leaves.map { |key| { "op" => "delete", "key" => key } }
33
+
34
+ plan = Textus::Jobs::Plan.new(steps: steps, warnings: warnings)
35
+ return plan if @dry_run
36
+
37
+ steps.each do |step|
38
+ Textus::Action::KeyDelete.new(key: step["key"]).call(container: container, call: call)
39
+ end
40
+ plan
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class KeyMv < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_mv
9
+ summary "Rename one entry (same zone + format). Refuses if the target exists. Single-key, lower blast radius than key_mv_prefix."
10
+ surfaces :cli, :mcp
11
+ cli "key mv"
12
+ arg :old_key, String, required: true, positional: true,
13
+ description: "current dotted key"
14
+ arg :new_key, String, required: true, positional: true,
15
+ description: "new dotted key (must be the same zone and format as old_key)"
16
+ arg :dry_run, :boolean,
17
+ description: "when true, returns the planned move (from/to paths, uid) without applying it; " \
18
+ "defaults to false, so omitting it applies the move immediately " \
19
+ "(unlike the bulk key_mv_prefix, which defaults to a dry-run plan)"
20
+
21
+ def initialize(old_key:, new_key:, dry_run: false)
22
+ super()
23
+ @old_key = old_key
24
+ @new_key = new_key
25
+ @dry_run = dry_run
26
+ end
27
+
28
+ def call(container:, call:)
29
+ run_with_cascade(cascade_target_key, container:, call:) do
30
+ execute_move(container, call)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def cascade_target_key
37
+ @dry_run ? nil : @new_key
38
+ end
39
+
40
+ def execute_move(container, call)
41
+ old_res, new_res = prepare(container, call)
42
+ return dry_run_result(container, old_res, new_res) if @dry_run
43
+
44
+ envelope = apply_move(container, call, old_res, new_res)
45
+ success_result(old_res, new_res, envelope)
46
+ end
47
+
48
+ def apply_move(container, call, old_res, new_res)
49
+ ensure_uid!(container, call, old_res.entry)
50
+ writer(container, call).move(
51
+ from_key: @old_key,
52
+ to_key: @new_key,
53
+ new_mentry: new_res.entry,
54
+ )
55
+ end
56
+
57
+ def success_result(old_res, new_res, envelope)
58
+ {
59
+ "protocol" => PROTOCOL,
60
+ "ok" => true,
61
+ "from_key" => @old_key,
62
+ "to_key" => @new_key,
63
+ "from_path" => old_res.path,
64
+ "to_path" => new_res.path,
65
+ "uid" => envelope.uid,
66
+ "envelope" => envelope.to_h_for_wire,
67
+ }
68
+ end
69
+
70
+ def prepare(container, call)
71
+ Textus::Manifest::Data.validate_key!(@old_key)
72
+ Textus::Manifest::Data.validate_key!(@new_key)
73
+ raise UsageError.new("mv: old and new keys are identical") if @old_key == @new_key
74
+
75
+ old_res = container.manifest.resolver.resolve(@old_key)
76
+ new_res = container.manifest.resolver.resolve(@new_key)
77
+ raise UnknownKey.new(@old_key) unless reader(container).exists?(@old_key)
78
+
79
+ validate_zone_and_format!(old_res.entry, new_res.entry)
80
+ auth(container).check_action!(action: :key_mv, actor: call.role, key: @old_key)
81
+ auth(container).check_action!(action: :key_mv, actor: call.role, key: @new_key)
82
+ raise UsageError.new("mv: target '#{@new_key}' already exists at #{new_res.path}") if reader(container).exists?(@new_key)
83
+
84
+ [old_res, new_res]
85
+ end
86
+
87
+ def validate_zone_and_format!(old_mentry, new_mentry)
88
+ if old_mentry.lane != new_mentry.lane
89
+ raise UsageError.new(
90
+ "mv: cross-zone move refused (#{old_mentry.lane} -> #{new_mentry.lane}). " \
91
+ "Use put+delete for cross-zone moves.",
92
+ )
93
+ end
94
+ return if old_mentry.format == new_mentry.format
95
+
96
+ raise UsageError.new("mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
97
+ end
98
+
99
+ def ensure_uid!(container, call, old_mentry)
100
+ pre_env = reader(container).read(@old_key)
101
+ return if pre_env.uid
102
+
103
+ writer(container, call).put(
104
+ @old_key,
105
+ mentry: old_mentry,
106
+ payload: Textus::Envelope::Writer::Payload.new(
107
+ meta: pre_env.meta,
108
+ body: pre_env.body,
109
+ content: pre_env.content,
110
+ ),
111
+ )
112
+ end
113
+
114
+ def dry_run_result(container, old_res, new_res)
115
+ pre_env = reader(container).read(@old_key)
116
+ {
117
+ "protocol" => PROTOCOL,
118
+ "ok" => true,
119
+ "dry_run" => true,
120
+ "from_key" => @old_key,
121
+ "to_key" => @new_key,
122
+ "from_path" => old_res.path,
123
+ "to_path" => new_res.path,
124
+ "uid" => pre_env.uid,
125
+ }
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class KeyMvPrefix < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_mv_prefix
9
+ summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
10
+ surfaces :cli, :mcp
11
+ cli "key mv-prefix"
12
+ arg :from_prefix, String, required: true, positional: true,
13
+ description: "dotted prefix whose leaf keys are renamed"
14
+ arg :to_prefix, String, required: true, positional: true,
15
+ description: "dotted prefix the keys are renamed to"
16
+ arg :dry_run, :boolean, default: false,
17
+ description: "when true, returns the planned moves without applying them; defaults " \
18
+ "to false, so omitting it applies the rename immediately"
19
+ view { |v, _i| v.to_h }
20
+
21
+ def initialize(from_prefix:, to_prefix:, dry_run: false)
22
+ super()
23
+ @from_prefix = from_prefix
24
+ @to_prefix = to_prefix
25
+ @dry_run = dry_run
26
+ end
27
+
28
+ def call(container:, call:)
29
+ raise UsageError.new("from_prefix and to_prefix required") if @from_prefix.nil? || @to_prefix.nil?
30
+
31
+ leaves = Textus::Action::List.new(prefix: @from_prefix).call(container: container)
32
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
33
+
34
+ if leaves.include?(@from_prefix)
35
+ raise UsageError.new("from_prefix '#{@from_prefix}' is itself a leaf — use `mv` to rename a single key")
36
+ end
37
+
38
+ warnings = []
39
+ warnings << "no keys under #{@from_prefix}" if leaves.empty?
40
+
41
+ steps = leaves.map do |old_key|
42
+ tail = old_key.delete_prefix("#{@from_prefix}.")
43
+ new_key = "#{@to_prefix}.#{tail}"
44
+ { "op" => "mv", "from" => old_key, "to" => new_key }
45
+ end
46
+
47
+ plan = Textus::Jobs::Plan.new(steps: steps, warnings: warnings)
48
+ return plan if @dry_run
49
+
50
+ steps.each do |step|
51
+ Textus::Action::KeyMv.new(old_key: step["from"], new_key: step["to"]).call(container: container, call: call)
52
+ end
53
+ plan
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class List < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :list
9
+ summary "List keys filtered by lane and/or prefix."
10
+ surfaces :cli, :mcp
11
+ arg :prefix, String,
12
+ description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
13
+ arg :lane, String,
14
+ description: "restrict to one lane by name (see `boot` lanes); combine with prefix to narrow further"
15
+ view(:cli) { |rows| { "entries" => rows } }
16
+
17
+ def initialize(prefix: nil, lane: nil)
18
+ super()
19
+ @prefix = prefix
20
+ @lane = lane
21
+ end
22
+
23
+ def call(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
24
+ manifest = container.manifest
25
+ rows = manifest.resolver.enumerate(prefix: @prefix)
26
+ rows = rows.select { |row| row[:manifest_entry].lane == @lane } if @lane
27
+ rows.map { |row| { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] } }
28
+ end
29
+
30
+ def self.new(*args, **kwargs)
31
+ return super(**kwargs) unless args.any?
32
+
33
+ call_spec = instance_method(:initialize).parameters
34
+ required = call_spec.slice(:keyreq).map(&:last)
35
+ optional = call_spec.slice(:key).map(&:last)
36
+ positional = required + optional
37
+ mapped = positional.zip(args).to_h
38
+ super(**mapped.merge(kwargs))
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Propose < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :propose
9
+ summary "Write a proposal to the role's propose_lane. Auto-prefixes the key."
10
+ surfaces :cli, :mcp
11
+ cli_stdin :json
12
+ arg :key, String, required: true, positional: true,
13
+ description: "key relative to propose_lane, e.g. 'decisions.feature-x'"
14
+ arg :meta, Hash, required: false, wire_name: :_meta,
15
+ description: "frontmatter; reads back as `_meta` from `get`. Include a 'proposal:' block naming the target_key"
16
+ arg :body, String,
17
+ description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
18
+ arg :content, Hash,
19
+ description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
20
+ view { |env, _i| env.to_h_for_wire }
21
+
22
+ def initialize(key:, meta: nil, body: nil, content: nil)
23
+ super()
24
+ @key = key
25
+ @meta = meta
26
+ @body = body
27
+ @content = content
28
+ end
29
+
30
+ def call(container:, call:)
31
+ zone = container.manifest.policy.propose_lane_for(call.role)
32
+ unless zone
33
+ raise Textus::Error.new(
34
+ "propose_forbidden",
35
+ "role '#{call.role}' has no writable propose_lane",
36
+ details: { "role" => call.role },
37
+ hint: "the manifest must define a queue zone and '#{call.role}' must hold the 'propose' capability",
38
+ )
39
+ end
40
+
41
+ run_with_cascade("#{zone}.#{@key}", container:, call:) do
42
+ Textus::Action::Put.new(
43
+ key: "#{zone}.#{@key}",
44
+ meta: @meta || {},
45
+ body: @body,
46
+ content: @content,
47
+ ).call(container: container, call: call)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Published < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :published
9
+ summary "List all entries that declare a publish_to target."
10
+ surfaces :cli
11
+ cli "published"
12
+
13
+ def args
14
+ {}
15
+ end
16
+
17
+ def call(container:, **)
18
+ container.manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
19
+ { "key" => entry.key, "publish_to" => entry.publish_to }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Textus
6
+ module Action
7
+ class Pulse < Base
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :pulse
11
+ summary "Delta since cursor — changed entries, pending proposals, index freshness."
12
+ surfaces :cli, :mcp
13
+ around :cursor
14
+ arg :since, Integer, session_default: :cursor,
15
+ description: "audit seq to diff from; defaults to the session cursor"
16
+
17
+ def initialize(since: nil)
18
+ super()
19
+ @since = since
20
+ end
21
+
22
+ def call(container:, call:)
23
+ @container = container
24
+ @call = call
25
+ @manifest = container.manifest
26
+ @audit_log = container.audit_log
27
+ @root = container.root
28
+
29
+ {
30
+ "cursor" => @audit_log.latest_seq,
31
+ "changed" => Textus::Action::Audit.new(seq_since: @since).call(container: container),
32
+ "pending_review" => review_keys,
33
+ "contract_etag" => Textus::Etag.for_contract(@root),
34
+ "index_etag" => index_etag(container),
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def review_keys
41
+ queue = @manifest.policy.queue_lane
42
+ return [] unless queue
43
+
44
+ rows = Textus::Action::List.new(lane: queue).call(container: @container)
45
+ rows.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
46
+ end
47
+
48
+ def index_etag(container)
49
+ path = container.manifest.resolver.resolve("artifacts.index").path
50
+ File.exist?(path) ? container.file_store.etag(path) : nil
51
+ rescue Textus::Error
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Put < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :put
9
+ summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
10
+ surfaces :cli, :mcp
11
+ arg :key, String, required: true, positional: true,
12
+ description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"
13
+ arg :meta, Hash, required: false, wire_name: :_meta,
14
+ description: "frontmatter; reads back as `_meta` from `get`. Schema-validated — call `schema KEY` first"
15
+ arg :body, String,
16
+ description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
17
+ arg :content, Hash,
18
+ description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
19
+ arg :if_etag, String,
20
+ description: "optimistic-concurrency guard: the etag you last read; the write is rejected if the entry changed since"
21
+ view { |env| { "uid" => env.uid, "etag" => env.etag } }
22
+
23
+ def initialize(key:, meta: nil, body: nil, content: nil, if_etag: nil)
24
+ super()
25
+ @key = key
26
+ @meta = meta
27
+ @body = body
28
+ @content = content
29
+ @if_etag = if_etag
30
+ end
31
+
32
+ def call(container:, call:)
33
+ run_with_cascade(@key, container:, call:) do
34
+ Textus::Manifest::Data.validate_key!(@key)
35
+ mentry = container.manifest.resolver.resolve(@key).entry
36
+ envelope = writer(container, call).put(
37
+ @key,
38
+ mentry: mentry,
39
+ payload: Textus::Envelope::Writer::Payload.new(
40
+ meta: @meta,
41
+ body: @body,
42
+ content: @content,
43
+ ),
44
+ if_etag: @if_etag,
45
+ )
46
+
47
+ envelope
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Rdeps < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :rdeps
9
+ summary "List the derived entries that depend on a key (reverse deps / impact set)."
10
+ surfaces :cli, :mcp
11
+ arg :key, String, required: true, positional: true,
12
+ description: "dotted key whose dependents (what would be stranded if it moved) you want"
13
+
14
+ def initialize(key:)
15
+ super()
16
+ @key = key
17
+ end
18
+
19
+ def call(container:, **)
20
+ manifest = container.manifest
21
+ rdeps = manifest.data.entries.each_with_object([]) do |entry, acc|
22
+ next unless entry.external?
23
+
24
+ sources = Array(entry.source&.sources).compact
25
+ acc << entry.key if sources.any? { |source| source == @key || @key.start_with?("#{source}.") }
26
+ end
27
+ { "key" => @key, "rdeps" => rdeps }
28
+ end
29
+
30
+ def self.new(*args, **kwargs)
31
+ return super(**kwargs) unless args.any?
32
+
33
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
34
+ mapped = positional.zip(args).to_h
35
+ super(**mapped.merge(kwargs))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Reject < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :reject
9
+ summary "discard a queued proposal without applying it"
10
+ surfaces :cli, :mcp
11
+ cli "reject"
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
+ run_with_cascade(@pending_key, container:, call:) do
21
+ mentry = container.manifest.resolver.resolve(@pending_key).entry
22
+ unless mentry.in_proposal_lane?(container.manifest.policy)
23
+ raise ProposalError.new("reject: '#{@pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
24
+ end
25
+
26
+ env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
27
+ proposal = env.meta&.dig("proposal") or raise ProposalError.new("entry has no proposal block: #{@pending_key}")
28
+ target_key = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
29
+
30
+ writer(container, call).delete(@pending_key, mentry: mentry)
31
+
32
+ { "protocol" => PROTOCOL, "rejected" => @pending_key, "target_key" => target_key }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class RuleExplain < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :rule_explain
9
+ summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
10
+ surfaces :cli, :mcp
11
+ cli "rule explain"
12
+ arg :key, String, required: true, positional: true,
13
+ description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
14
+ arg :detail, :boolean,
15
+ description: "defaults false: lean {lifecycle, guard}. detail: true adds matched blocks + guard predicates per transition."
16
+ view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
17
+
18
+ def initialize(key:, detail: nil)
19
+ super()
20
+ @key = key
21
+ @detail = detail
22
+ end
23
+
24
+ def call(container:, **)
25
+ @manifest = container.manifest
26
+ @detail ? explain(@key) : effective(@key)
27
+ end
28
+
29
+ REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
30
+ LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
31
+ DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
32
+ EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
33
+
34
+ private
35
+
36
+ def effective(key)
37
+ set = @manifest.rules.for(key)
38
+ LEAN_FIELDS.each_with_object({}) do |field, out|
39
+ value = set.public_send(field)
40
+ out[field.to_s] = lean_value(field, value) unless value.nil?
41
+ end
42
+ end
43
+
44
+ def lean_value(field, value)
45
+ case field
46
+ when :retention
47
+ retention_hash(value, string_keys: true)
48
+ when :react
49
+ value.to_h
50
+ else
51
+ value
52
+ end
53
+ end
54
+
55
+ def explain(key)
56
+ matching = @manifest.rules.explain(key)
57
+ winners = @manifest.rules.for(key)
58
+ {
59
+ key: key,
60
+ matched_blocks: matching.map do |block|
61
+ { match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
62
+ end,
63
+ effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
64
+ guards: Textus::Gate::Auth::FLOOR.keys.to_h do |action|
65
+ floor = Textus::Gate::Auth::FLOOR.fetch(action, [])
66
+ rule = Array(@manifest.rules.for(key).guard&.dig(action.to_s))
67
+ [action, { floor: floor, rule: rule }]
68
+ end,
69
+ }
70
+ end
71
+
72
+ def effective_value(field, value)
73
+ return nil if value.nil?
74
+
75
+ case field
76
+ when :retention
77
+ retention_hash(value, string_keys: false)
78
+ when :react
79
+ value.to_h
80
+ else
81
+ value
82
+ end
83
+ end
84
+
85
+ def retention_hash(retention, string_keys:)
86
+ h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
87
+ string_keys ? h.transform_keys(&:to_s) : h
88
+ end
89
+ end
90
+ end
91
+ end