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
@@ -0,0 +1,44 @@
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
+ BURN = :sync
18
+
19
+ def initialize(prefix: nil, lane: nil)
20
+ super()
21
+ @prefix = prefix
22
+ @lane = lane
23
+ end
24
+
25
+ def call(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
26
+ manifest = container.manifest
27
+ rows = manifest.resolver.enumerate(prefix: @prefix)
28
+ rows = rows.select { |row| row[:manifest_entry].lane == @lane } if @lane
29
+ rows.map { |row| { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] } }
30
+ end
31
+
32
+ def self.new(*args, **kwargs)
33
+ return super(**kwargs) unless args.any?
34
+
35
+ call_spec = instance_method(:initialize).parameters
36
+ required = call_spec.slice(:keyreq).map(&:last)
37
+ optional = call_spec.slice(:key).map(&:last)
38
+ positional = required + optional
39
+ mapped = positional.zip(args).to_h
40
+ super(**mapped.merge(kwargs))
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,54 @@
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
+ BURN = :sync
23
+
24
+ def initialize(key:, meta: nil, body: nil, content: nil)
25
+ super()
26
+ @key = key
27
+ @meta = meta
28
+ @body = body
29
+ @content = content
30
+ end
31
+
32
+ def call(container:, call:)
33
+ zone = container.manifest.policy.propose_lane_for(call.role)
34
+ unless zone
35
+ raise Textus::Error.new(
36
+ "propose_forbidden",
37
+ "role '#{call.role}' has no writable propose_lane",
38
+ details: { "role" => call.role },
39
+ hint: "the manifest must define a queue zone and '#{call.role}' must hold the 'propose' capability",
40
+ )
41
+ end
42
+
43
+ run_with_cascade("#{zone}.#{@key}", container:, call:) do
44
+ Textus::Action::Put.new(
45
+ key: "#{zone}.#{@key}",
46
+ meta: @meta || {},
47
+ body: @body,
48
+ content: @content,
49
+ ).call(container: container, call: call)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,26 @@
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
+ BURN = :sync
14
+
15
+ def args
16
+ {}
17
+ end
18
+
19
+ def call(container:, **)
20
+ container.manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
21
+ { "key" => entry.key, "publish_to" => entry.publish_to }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Textus
6
+ module Action
7
+ class Pulse
8
+ class Scanner
9
+ def initialize(prefix: nil, lane: nil)
10
+ @prefix = prefix
11
+ @lane = lane
12
+ end
13
+
14
+ def call(container:, call:)
15
+ @container = container
16
+ @call = call
17
+ @manifest = container.manifest
18
+ @file_store = container.file_store
19
+
20
+ rows = []
21
+ @manifest.data.entries.each do |mentry|
22
+ next if @prefix && !mentry.key.start_with?(@prefix)
23
+ next if @lane && mentry.lane != @lane
24
+
25
+ rows << row_for(mentry)
26
+ end
27
+ rows
28
+ end
29
+
30
+ private
31
+
32
+ def row_for(mentry)
33
+ envelope = safe_get(mentry.key)
34
+ last = envelope&.meta&.dig("last_fetched_at")
35
+ ttl, action = policy_for(mentry)
36
+ return base_row(mentry, last).merge(status: :no_policy) if ttl.nil?
37
+
38
+ basis = basis_for(mentry)
39
+ expired = expired?(mentry, basis, ttl)
40
+ base_row(mentry, last).merge(
41
+ ttl_seconds: ttl,
42
+ action: action,
43
+ status: expired ? :expired : :fresh,
44
+ next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
45
+ )
46
+ end
47
+
48
+ def policy_for(mentry)
49
+ if mentry.intake?
50
+ ttl = mentry.source.ttl_seconds
51
+ return [ttl, :refresh] unless ttl.nil?
52
+ end
53
+ ret = @manifest.rules.for(mentry.key).retention
54
+ return [ret.ttl_seconds, ret.action] unless ret.nil?
55
+
56
+ [nil, nil]
57
+ end
58
+
59
+ def basis_for(mentry)
60
+ return evaluator.intake_basis(mentry) if mentry.intake? && mentry.source.ttl_seconds
61
+
62
+ mtime_for(mentry.key)
63
+ end
64
+
65
+ def expired?(mentry, basis, ttl)
66
+ if mentry.intake? && mentry.source.ttl_seconds
67
+ evaluator.verdict(mentry).stale
68
+ else
69
+ basis.nil? || Textus::Core::Retention::Sweep.expired?(ttl_seconds: ttl, mtime: basis, now: @call.now)
70
+ end
71
+ end
72
+
73
+ def evaluator
74
+ @evaluator ||= Textus::Core::Freshness::Evaluator.new(
75
+ manifest: @manifest,
76
+ file_stat: Textus::Ports::Storage::FileStat.new,
77
+ clock: @call,
78
+ )
79
+ end
80
+
81
+ def mtime_for(key)
82
+ path = @manifest.resolver.resolve(key).path
83
+ @file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
84
+ rescue Textus::Error
85
+ nil
86
+ end
87
+
88
+ def base_row(mentry, last)
89
+ {
90
+ key: mentry.key,
91
+ lane: mentry.lane,
92
+ last_fetched_at: last,
93
+ age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
94
+ }
95
+ end
96
+
97
+ def safe_get(key)
98
+ res = @manifest.resolver.resolve(key)
99
+ return nil unless @file_store.exists?(res.path)
100
+
101
+ raw = @file_store.read(res.path)
102
+ parsed = Textus::Entry.for_format(res.entry.format).parse(raw, path: res.path)
103
+ Textus::Envelope.build(
104
+ key: key,
105
+ mentry: res.entry,
106
+ path: res.path,
107
+ meta: parsed["_meta"],
108
+ body: parsed["body"],
109
+ etag: Textus::Etag.for_bytes(raw),
110
+ content: parsed["content"],
111
+ )
112
+ rescue Textus::Error
113
+ nil
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,87 @@
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, stale, pending proposals, doctor summary."
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
+ BURN = :sync
18
+
19
+ def initialize(since: nil)
20
+ super()
21
+ @since = since
22
+ end
23
+
24
+ def call(container:, call:)
25
+ @container = container
26
+ @call = call
27
+ @manifest = container.manifest
28
+ @audit_log = container.audit_log
29
+ @root = container.root
30
+ @steps = container.steps
31
+
32
+ freshness_rows = Pulse::Scanner.new.call(container: container, call: call)
33
+ {
34
+ "cursor" => @audit_log.latest_seq,
35
+ "changed" => Textus::Action::Audit.new(seq_since: @since).call(container: container),
36
+ "stale" => freshness_rows.select { |row| row[:status] == :expired }.map { |row| row[:key] },
37
+ "pending_review" => review_keys,
38
+ "doctor" => doctor_summary,
39
+ "contract_etag" => Textus::Etag.for_contract(@root),
40
+ "next_due_at" => soonest_due(freshness_rows),
41
+ "hook_errors" => hook_errors_since(@since || 0),
42
+ }
43
+ end
44
+
45
+ private
46
+
47
+ def soonest_due(rows)
48
+ times = rows.map { |row| row[:next_due_at] }.compact.map { |t| Time.parse(t) }
49
+ return nil if times.empty?
50
+
51
+ times.min.utc.iso8601
52
+ end
53
+
54
+ def review_keys
55
+ queue = @manifest.policy.queue_lane
56
+ return [] unless queue
57
+
58
+ rows = Textus::Action::List.new(lane: queue).call(container: @container)
59
+ rows.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
60
+ end
61
+
62
+ def doctor_summary
63
+ result = Textus::Doctor.build(container: @container)
64
+ issues = result["issues"] || []
65
+ {
66
+ "ok" => result["ok"],
67
+ "warn" => issues.count { |i| i["level"] == "warning" },
68
+ "fail" => issues.count { |i| i["level"] == "error" },
69
+ }
70
+ end
71
+
72
+ def hook_errors_since(seq)
73
+ @steps.error_log.since(seq).map do |row|
74
+ {
75
+ "seq" => row[:seq],
76
+ "event" => row[:event].to_s,
77
+ "hook" => row[:hook].to_s,
78
+ "key" => row[:key],
79
+ "error_class" => row[:error_class],
80
+ "error_message" => row[:error_message],
81
+ "at" => row[:at],
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,63 @@
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
+ BURN = :sync
24
+
25
+ def initialize(key:, meta: nil, body: nil, content: nil, if_etag: nil)
26
+ super()
27
+ @key = key
28
+ @meta = meta
29
+ @body = body
30
+ @content = content
31
+ @if_etag = if_etag
32
+ end
33
+
34
+ def call(container:, call:)
35
+ run_with_cascade(@key, container:, call:) do
36
+ Textus::Manifest::Data.validate_key!(@key)
37
+ mentry = container.manifest.resolver.resolve(@key).entry
38
+ auth(container).check_action!(action: :put, actor: call.role, key: @key, extra: { if_etag: @if_etag })
39
+
40
+ envelope = writer(container, call).put(
41
+ @key,
42
+ mentry: mentry,
43
+ payload: Textus::Envelope::IO::Writer::Payload.new(
44
+ meta: @meta,
45
+ body: @body,
46
+ content: @content,
47
+ ),
48
+ if_etag: @if_etag,
49
+ )
50
+
51
+ container.steps.publish(
52
+ :entry_written,
53
+ ctx: Textus::Step::Context.for(container: container, call: call),
54
+ key: @key,
55
+ envelope: envelope,
56
+ )
57
+
58
+ envelope
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,49 @@
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
+ BURN = :sync
15
+
16
+ def initialize(key:)
17
+ super()
18
+ @key = key
19
+ end
20
+
21
+ def call(container:, **)
22
+ manifest = container.manifest
23
+ rdeps = manifest.data.entries.each_with_object([]) do |entry, acc|
24
+ next unless entry.derived?
25
+
26
+ src = entry.source
27
+ sources =
28
+ if src.projection?
29
+ Array(src.select).compact
30
+ elsif src.external?
31
+ Array(src.sources).compact
32
+ else
33
+ []
34
+ end
35
+ acc << entry.key if sources.any? { |source| source == @key || @key.start_with?("#{source}.") }
36
+ end
37
+ { "key" => @key, "rdeps" => rdeps }
38
+ end
39
+
40
+ def self.new(*args, **kwargs)
41
+ return super(**kwargs) unless args.any?
42
+
43
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
44
+ mapped = positional.zip(args).to_h
45
+ super(**mapped.merge(kwargs))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,49 @@
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
+ BURN = :sync
15
+
16
+ def initialize(pending_key:)
17
+ super()
18
+ @pending_key = pending_key
19
+ end
20
+
21
+ def call(container:, call:)
22
+ run_with_cascade(@pending_key, container:, call:) do
23
+ auth = Textus::Gate::Auth.new(container)
24
+ auth.check_action!(action: :reject, actor: call.role, key: @pending_key)
25
+
26
+ mentry = container.manifest.resolver.resolve(@pending_key).entry
27
+ unless mentry.in_proposal_lane?(container.manifest.policy)
28
+ raise ProposalError.new("reject: '#{@pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
29
+ end
30
+
31
+ env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
32
+ proposal = env.meta&.dig("proposal") or raise ProposalError.new("entry has no proposal block: #{@pending_key}")
33
+ target_key = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
34
+
35
+ writer(container, call).delete(@pending_key, mentry: mentry)
36
+
37
+ container.steps.publish(
38
+ :proposal_rejected,
39
+ ctx: Textus::Step::Context.for(container: container, call: call),
40
+ key: @pending_key,
41
+ target_key: target_key,
42
+ )
43
+
44
+ { "protocol" => PROTOCOL, "rejected" => @pending_key, "target_key" => target_key }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,95 @@
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
+ BURN = :sync
19
+
20
+ def initialize(key:, detail: nil)
21
+ super()
22
+ @key = key
23
+ @detail = detail
24
+ end
25
+
26
+ def call(container:, **)
27
+ @manifest = container.manifest
28
+ @detail ? explain(@key) : effective(@key)
29
+ end
30
+
31
+ REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
32
+ LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
33
+ DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
34
+ EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
35
+
36
+ private
37
+
38
+ def effective(key)
39
+ set = @manifest.rules.for(key)
40
+ LEAN_FIELDS.each_with_object({}) do |field, out|
41
+ value = set.public_send(field)
42
+ out[field.to_s] = lean_value(field, value) unless value.nil?
43
+ end
44
+ end
45
+
46
+ def lean_value(field, value)
47
+ case field
48
+ when :retention
49
+ retention_hash(value, string_keys: true)
50
+ when :react
51
+ value.to_h
52
+ else
53
+ value
54
+ end
55
+ end
56
+
57
+ def explain(key)
58
+ matching = @manifest.rules.explain(key)
59
+ winners = @manifest.rules.for(key)
60
+ {
61
+ key: key,
62
+ matched_blocks: matching.map do |block|
63
+ { match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
64
+ end,
65
+ effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
66
+ guards: Textus::Gate::Auth::FLOOR.keys.to_h do |action|
67
+ floor = Textus::Gate::Auth::FLOOR.fetch(action, [])
68
+ rule = Array(@manifest.rules.for(key).guard&.dig(action.to_s))
69
+ [action, { floor: floor, rule: rule }]
70
+ end,
71
+ }
72
+ end
73
+
74
+ def effective_value(field, value)
75
+ return nil if value.nil?
76
+
77
+ case field
78
+ when :retention
79
+ retention_hash(value, string_keys: false)
80
+ when :react
81
+ value.to_h
82
+ when :handler_permit
83
+ value.handlers
84
+ else
85
+ value
86
+ end
87
+ end
88
+
89
+ def retention_hash(retention, string_keys:)
90
+ h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
91
+ string_keys ? h.transform_keys(&:to_s) : h
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Textus
6
+ module Action
7
+ class RuleLint < Base
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :rule_lint
11
+ summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
12
+ surfaces :cli, :mcp
13
+ cli "rule lint"
14
+ arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
15
+ description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
16
+ view { |v, _i| v.to_h }
17
+
18
+ BURN = :sync
19
+
20
+ def initialize(candidate_yaml:)
21
+ super()
22
+ @candidate_yaml = candidate_yaml
23
+ end
24
+
25
+ def call(container:, **)
26
+ root = container.root
27
+ live_rules = current_rules(root)
28
+ candidate_rules = parse_candidate(@candidate_yaml)
29
+
30
+ live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
31
+ candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
32
+
33
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
34
+ { "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
35
+ end
36
+ (live_by_match.keys - candidate_by_match.keys).each do |match|
37
+ steps << { "op" => "remove_rule", "match" => match }
38
+ end
39
+ (live_by_match.keys & candidate_by_match.keys).each do |match|
40
+ next if live_by_match[match] == candidate_by_match[match]
41
+
42
+ steps << {
43
+ "op" => "change_rule",
44
+ "match" => match,
45
+ "from" => live_by_match[match],
46
+ "to" => candidate_by_match[match],
47
+ }
48
+ end
49
+
50
+ Textus::Background::Plan.new(steps: steps, warnings: [])
51
+ end
52
+
53
+ private
54
+
55
+ def current_rules(root)
56
+ raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
57
+ Array(raw["rules"])
58
+ end
59
+
60
+ def parse_candidate(yaml_text)
61
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
62
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
63
+
64
+ Array(raw["rules"])
65
+ rescue Psych::Exception => e
66
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
67
+ end
68
+ end
69
+ end
70
+ end