textus 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. data/lib/textus/write/reject.rb +0 -68
@@ -1,130 +0,0 @@
1
- require "json"
2
- require "time"
3
-
4
- module Textus
5
- module Read
6
- # Queries .textus/.run/audit/audit.log. Filters: key, zone, role, verb, since,
7
- # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
8
- # rows produce nil and are skipped).
9
- class Audit
10
- # Value object that carries all filter parameters for an audit query.
11
- # `matches?` checks the manifest-independent predicates so the loop body
12
- # only needs to handle the zone check (which requires manifest access).
13
- Query = Data.define(:key, :zone, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
14
- # rubocop:disable Metrics/ParameterLists
15
- def self.build(key: nil, zone: nil, role: nil, verb: nil,
16
- since: nil, seq_since: nil, correlation_id: nil, limit: nil)
17
- new(key:, zone:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
18
- end
19
- # rubocop:enable Metrics/ParameterLists
20
-
21
- def matches?(row)
22
- return false if key && row["key"] != key
23
- return false if role && row["role"] != role
24
- return false if verb && row["verb"] != verb
25
- return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
26
- return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
27
- return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
28
-
29
- true
30
- end
31
- end
32
-
33
- extend Textus::Contract::DSL
34
-
35
- verb :audit
36
- summary "Query the audit log with optional filters."
37
- surfaces :cli
38
- cli "audit"
39
- # #call(**filters) — args map to Query.build keyword params (ADR 0063)
40
- arg :key, String, required: false, description: "filter to rows for this key"
41
- arg :zone, String, required: false, description: "filter to keys in this zone"
42
- arg :role, String, required: false, description: "filter to rows written under this role"
43
- arg :verb, String, required: false, description: "filter to rows for this verb"
44
- arg :since, String, required: false,
45
- coerce: ->(s) { Textus::Read::Audit.parse_since(s, now: Time.now) },
46
- description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
47
- arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
48
- arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
49
- arg :limit, Integer, required: false, description: "maximum number of rows to return"
50
- view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
51
-
52
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
53
- @manifest = container.manifest
54
- @root = container.root
55
- @log_path = Textus::Layout.audit_log(container.root)
56
- @audit_log = container.audit_log
57
- end
58
-
59
- def call(**filters)
60
- query = Query.build(**filters)
61
- check_cursor_expiry!(query.seq_since)
62
-
63
- files = all_log_files
64
- return [] if files.empty?
65
-
66
- rows = []
67
- files.each do |file|
68
- File.foreach(file) do |line|
69
- parsed = parse_row(line.chomp)
70
- next unless parsed
71
- next unless query.matches?(parsed)
72
- next if query.zone && !key_in_zone?(parsed["key"], query.zone)
73
-
74
- rows << parsed
75
- break if limit_reached?(rows, query)
76
- end
77
- break if limit_reached?(rows, query)
78
- end
79
- rows
80
- end
81
-
82
- # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
83
- # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
84
- def self.parse_since(str, now: Time.now.utc)
85
- return nil if str.nil? || str.empty?
86
- return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
87
-
88
- m = str.match(/\A(\d+)([smhd])\z/) or return nil
89
- mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
90
- now - (m[1].to_i * mult)
91
- end
92
-
93
- private
94
-
95
- def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
96
-
97
- def check_cursor_expiry!(seq_since)
98
- return unless seq_since
99
-
100
- log = @audit_log || Textus::Ports::AuditLog.new(@root)
101
- min = log.min_available_seq
102
- raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
103
- end
104
-
105
- def all_log_files
106
- rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
107
- .reject { |p| p.end_with?(".meta.json") }
108
- .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
109
- active = File.exist?(@log_path) ? [@log_path] : []
110
- rotated + active
111
- end
112
-
113
- def parse_row(line)
114
- return nil if line.empty?
115
- return nil unless line.start_with?("{")
116
-
117
- JSON.parse(line)
118
- rescue JSON::ParserError
119
- nil
120
- end
121
-
122
- def key_in_zone?(key, zone)
123
- mentry = @manifest.resolver.resolve(key).entry
124
- mentry && mentry.zone == zone
125
- rescue Textus::Error
126
- false
127
- end
128
- end
129
- end
130
- end
@@ -1,26 +0,0 @@
1
- module Textus
2
- module Read
3
- # Dispatched use case for the `boot` verb. The orientation envelope is
4
- # built by the Textus::Boot library module; this class is the uniform
5
- # (container:, call:) entry point that Dispatcher::VERBS resolves to.
6
- # Boot is role-independent, so `call` is not consulted.
7
- class Boot
8
- extend Textus::Contract::DSL
9
-
10
- verb :boot
11
- summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
12
- surfaces :cli, :mcp
13
- arg :lean, :boolean,
14
- description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
15
-
16
- def initialize(container:, call:)
17
- @container = container
18
- @call = call
19
- end
20
-
21
- def call(lean: false)
22
- Textus::Boot.build(container: @container, lean: lean)
23
- end
24
- end
25
- end
26
- end
@@ -1,70 +0,0 @@
1
- module Textus
2
- module Read
3
- # A machine-readable projection of the contract surface: every verb, the
4
- # transports it reaches, and its full argument schema — sourced from the
5
- # same Contract DSL the CLI/MCP/boot already project from (ADR 0039/0063).
6
- #
7
- # Integrators assert their docs against this in CI so they can't drift
8
- # (#161 F4 — patrick-nexus docs claimed "MCP exposes 3 verbs" while ~20 are
9
- # surfaced). It also makes the per-surface `dry_run` default asymmetry
10
- # (#161 F6) self-documenting: each arg carries both `default` (agent wire)
11
- # and `cli_default` (CLI), so the divergence is visible, not folklore.
12
- #
13
- # Pure contract introspection — it reads no store data; `container` is
14
- # accepted only for the uniform use-case constructor.
15
- class Capabilities
16
- extend Textus::Contract::DSL
17
-
18
- verb :capabilities
19
- summary "Machine-readable contract surface: every verb, its transports, and arg schema."
20
- surfaces :cli, :mcp
21
- arg :verb, String, required: false, description: "filter to a single verb by name"
22
- view { |result, _i| result }
23
-
24
- def initialize(container: nil, call: nil); end
25
-
26
- def call(verb: nil)
27
- klasses = Textus::Dispatcher::VERBS.values.select { |k| contract?(k) }
28
- rows = klasses.map { |k| project(k.contract) }
29
- rows.select! { |r| r["verb"] == verb } if verb
30
- { "verbs" => rows.sort_by { |r| r["verb"] } }
31
- end
32
-
33
- private
34
-
35
- def contract?(klass)
36
- klass.respond_to?(:contract?) && klass.contract?
37
- end
38
-
39
- def project(spec)
40
- {
41
- "verb" => spec.verb.to_s,
42
- "summary" => spec.summary,
43
- "surfaces" => spec.surfaces.map(&:to_s) + ["ruby"],
44
- "cli" => spec.cli? ? spec.cli_path : nil,
45
- "args" => spec.args.map { |a| project_arg(a) },
46
- }
47
- end
48
-
49
- def project_arg(arg)
50
- out = {
51
- "name" => arg.wire.to_s,
52
- "type" => json_type(arg.type),
53
- "required" => arg.required,
54
- "positional" => arg.positional,
55
- }
56
- out["description"] = arg.description if arg.description
57
- out["default"] = arg.default unless arg.default.nil?
58
- out["cli_default"] = arg.cli_default unless arg.cli_default == :__unset
59
- out["session_default"] = arg.session_default.to_s if arg.session_default
60
- out
61
- end
62
-
63
- def json_type(type)
64
- Textus::Contract.json_type(type)
65
- rescue ArgumentError
66
- "string"
67
- end
68
- end
69
- end
70
- end
@@ -1,38 +0,0 @@
1
- module Textus
2
- module Read
3
- class Deps
4
- extend Textus::Contract::DSL
5
-
6
- verb :deps
7
- summary "List the keys a derived entry depends on (its projection/external sources)."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted key of the derived entry whose source keys you want"
11
-
12
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
13
- @manifest = container.manifest
14
- end
15
-
16
- def call(key)
17
- { "key" => key, "deps" => sources_for(key) }
18
- end
19
-
20
- private
21
-
22
- def sources_for(key)
23
- entry = @manifest.data.entries.find { |e| e.key == key }
24
- return [] unless entry&.derived?
25
-
26
- src = entry.source
27
- result = if src.projection?
28
- Array(src.select).compact
29
- elsif src.external?
30
- Array(src.sources).compact
31
- else
32
- []
33
- end
34
- result.uniq
35
- end
36
- end
37
- end
38
- end
@@ -1,27 +0,0 @@
1
- module Textus
2
- module Read
3
- # Dispatched use case for the `doctor` verb. The health-check report is
4
- # built by the Textus::Doctor library module; this class is the uniform
5
- # (container:, call:) entry point that Dispatcher::VERBS resolves to.
6
- # The acting role is irrelevant to a read-only health check, so `call`
7
- # is not consulted.
8
- class Doctor
9
- extend Textus::Contract::DSL
10
-
11
- verb :doctor
12
- summary "Run health checks on the textus store and report any issues."
13
- surfaces :cli
14
- cli "doctor"
15
- arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
16
-
17
- def initialize(container:, call:)
18
- @container = container
19
- @call = call
20
- end
21
-
22
- def call(checks: nil)
23
- Textus::Doctor.build(container: @container, checks: checks)
24
- end
25
- end
26
- end
27
- end
@@ -1,152 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Read
5
- # Per-entry staleness scan (ADR 0079, 0085, 0093). Walks every entry declared
6
- # in the manifest and reports a staleness verdict sourced from the two new
7
- # policy slots (ADR 0093):
8
- # - intake entries: `entry.source.ttl_seconds` is the re-pull cadence;
9
- # basis = `_meta.last_fetched_at` (else file mtime). Past ttl ⇒ :expired.
10
- # - entries matched by a `retention:` rule: `retention.ttl_seconds` is the
11
- # GC age; basis = file mtime. Past ttl ⇒ :expired (:action = drop/archive).
12
- # Intake cadence wins when both apply (freshness is content currency; GC dueness
13
- # shows via `drain --dry-run`).
14
- # Status is one of :fresh, :expired, or :no_policy; the row also carries
15
- # :action (:refresh for intake, :drop/:archive for retention).
16
- #
17
- # ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
18
- # surface. This is now a Ruby-only internal scan consumed by `pulse` (which
19
- # derives `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
20
- # into per-entry staleness detail via `get` (last_fetched_at) + `rule_explain`
21
- # (the ttl / action policy) instead of a dedicated verb.
22
- class Freshness
23
- extend Textus::Contract::DSL
24
-
25
- verb :freshness
26
- summary "Internal per-entry lifecycle scan (status, age, ttl, action); backs pulse + hook context. No public surface (ADR 0085)."
27
- arg :prefix, String, required: false, description: "filter to keys with this prefix"
28
- arg :zone, String, required: false, description: "filter to entries in this zone"
29
-
30
- def initialize(container:, call:)
31
- @container = container
32
- @call = call
33
- @manifest = container.manifest
34
- @file_store = container.file_store
35
- end
36
-
37
- # Returns the soonest `next_due_at` across all entries with a fetch
38
- # policy, as an ISO-8601 string, or nil if none.
39
- def soonest_due(prefix: nil, zone: nil)
40
- times = call(prefix: prefix, zone: zone)
41
- .map { |r| r[:next_due_at] }
42
- .compact
43
- .map { |t| Time.parse(t) }
44
- return nil if times.empty?
45
-
46
- times.min.utc.iso8601
47
- end
48
-
49
- def call(prefix: nil, zone: nil)
50
- rows = []
51
- @manifest.data.entries.each do |mentry|
52
- next if prefix && !mentry.key.start_with?(prefix)
53
- next if zone && mentry.zone != zone
54
-
55
- rows << row_for(mentry)
56
- end
57
- rows
58
- end
59
-
60
- private
61
-
62
- def row_for(mentry)
63
- envelope = safe_get(mentry.key)
64
- last = envelope&.meta&.dig("last_fetched_at")
65
- ttl, action = policy_for(mentry)
66
- return base_row(mentry, last).merge(status: :no_policy) if ttl.nil?
67
-
68
- basis = basis_for(mentry)
69
- expired = expired?(mentry, basis, ttl)
70
- base_row(mentry, last).merge(
71
- ttl_seconds: ttl,
72
- action: action,
73
- status: expired ? :expired : :fresh,
74
- next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
75
- )
76
- end
77
-
78
- # ADR 0093: staleness comes from the intake re-pull cadence (source.ttl)
79
- # or a retention GC rule (retention.ttl). Intake cadence wins when an entry
80
- # has both (freshness is about content currency; GC dueness still shows via
81
- # `drain --dry-run`). Returns [ttl_seconds, action] or [nil, nil].
82
- def policy_for(mentry)
83
- if mentry.intake?
84
- ttl = mentry.source.ttl_seconds
85
- return [ttl, :refresh] unless ttl.nil?
86
- end
87
- ret = @manifest.rules.for(mentry.key).retention
88
- return [ret.ttl_seconds, ret.action] unless ret.nil?
89
-
90
- [nil, nil]
91
- end
92
-
93
- # Intake currency basis comes from the evaluator (single definition);
94
- # retention dueness is keyed off file mtime.
95
- def basis_for(mentry)
96
- return evaluator.intake_basis(mentry) if mentry.intake? && mentry.source.ttl_seconds
97
-
98
- mtime_for(mentry.key)
99
- end
100
-
101
- def expired?(mentry, basis, ttl)
102
- if mentry.intake? && mentry.source.ttl_seconds
103
- evaluator.verdict(mentry).stale
104
- else
105
- # Preserve pre-0099 pulse semantics: a never-recorded retention entry
106
- # (no file => nil basis) is past due. Retention::Sweep.expired? alone
107
- # returns false on nil mtime (it runs post-exists? in the sweep).
108
- basis.nil? || Textus::Domain::Retention::Sweep.expired?(ttl_seconds: ttl, mtime: basis, now: @call.now)
109
- end
110
- end
111
-
112
- def evaluator
113
- @evaluator ||= Textus::Domain::Freshness::Evaluator.new(
114
- manifest: @manifest, file_stat: Textus::Ports::Storage::FileStat.new, clock: @call,
115
- )
116
- end
117
-
118
- def mtime_for(key)
119
- path = @manifest.resolver.resolve(key).path
120
- @file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
121
- rescue Textus::Error
122
- nil
123
- end
124
-
125
- def base_row(mentry, last)
126
- {
127
- key: mentry.key,
128
- zone: mentry.zone,
129
- last_fetched_at: last,
130
- age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
131
- }
132
- end
133
-
134
- # Returns the raw envelope or nil. Nested entries (mentry.key is a
135
- # prefix, not a leaf) and missing files both resolve to nil.
136
- def safe_get(key)
137
- res = @manifest.resolver.resolve(key)
138
- return nil unless @file_store.exists?(res.path)
139
-
140
- raw = @file_store.read(res.path)
141
- parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
142
- Textus::Envelope.build(
143
- key: key, mentry: res.entry, path: res.path,
144
- meta: parsed["_meta"], body: parsed["body"],
145
- etag: Etag.for_bytes(raw), content: parsed["content"]
146
- )
147
- rescue Textus::Error
148
- nil
149
- end
150
- end
151
- end
152
- end
@@ -1,73 +0,0 @@
1
- module Textus
2
- module Read
3
- # The one read path — a pure read (ADR 0089, 0093): the on-disk envelope
4
- # annotated with a freshness annotation. It NEVER mutates and NEVER ingests.
5
- # Quarantine freshness is system-pushed via `drain` (scheduled sweep) and
6
- # `hook run` (event push). Lifecycle is removed from the get path (ADR 0093):
7
- # intake cadence lives in `source.ttl`; GC lives in `retention:` rules; both
8
- # are evaluated exclusively by the `drain` sweep, not by a read.
9
- class Get
10
- extend Textus::Contract::DSL
11
-
12
- verb :get
13
- summary "Read one entry — a pure on-disk read annotated with a freshness " \
14
- "verdict; never ingests (quarantine freshness is drain + hook " \
15
- "only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
16
- "freshness)."
17
- surfaces :cli, :mcp
18
- arg :key, String, required: true, positional: true,
19
- description: "dotted entry key to read, e.g. 'knowledge.project'"
20
- view { |v, _i| v.to_h_for_wire }
21
-
22
- def initialize(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
23
- @container = container
24
- @call = call
25
- @manifest = container.manifest
26
- @file_store = container.file_store
27
- @file_stat = file_stat
28
- end
29
-
30
- def call(key)
31
- annotated_envelope(key)
32
- end
33
-
34
- # Strict variant: raises UnknownKey when the entry is missing.
35
- # Used by consumers (e.g. uid, Validator) that distinguish absence.
36
- def get(key)
37
- call(key) ||
38
- raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
39
- end
40
-
41
- private
42
-
43
- def annotated_envelope(key)
44
- envelope = read_raw_envelope(key)
45
- return nil if envelope.nil?
46
-
47
- entry = @manifest.resolver.resolve(key).entry
48
- envelope.with(freshness: evaluator.verdict(entry))
49
- end
50
-
51
- def evaluator
52
- @evaluator ||= Textus::Domain::Freshness::Evaluator.new(
53
- manifest: @manifest, file_stat: @file_stat, clock: @call,
54
- )
55
- end
56
-
57
- def read_raw_envelope(key)
58
- res = @manifest.resolver.resolve(key)
59
- mentry = res.entry
60
- path = res.path
61
- return nil unless @file_store.exists?(path)
62
-
63
- raw = @file_store.read(path)
64
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
65
- Textus::Envelope.build(
66
- key: key, mentry: mentry, path: path,
67
- meta: parsed["_meta"], body: parsed["body"],
68
- etag: Etag.for_bytes(raw), content: parsed["content"]
69
- )
70
- end
71
- end
72
- end
73
- end
@@ -1,31 +0,0 @@
1
- module Textus
2
- module Read
3
- # Inspect and operate the job queue: list ids by state, retry a dead-lettered
4
- # job, or purge a state. The agent's window into deferred convergence work.
5
- class Jobs
6
- extend Textus::Contract::DSL
7
-
8
- verb :jobs
9
- summary "List queued jobs by state; retry a dead-lettered job or purge a state."
10
- surfaces :cli, :mcp
11
- cli "jobs"
12
- arg :state, String, default: "ready", description: "ready|leased|done|failed"
13
- arg :action, String, default: nil, description: "retry|purge (optional)"
14
- arg :job_id, String, default: nil, description: "job id (required for action=retry)"
15
-
16
- def initialize(container:, call:)
17
- @container = container
18
- @call = call
19
- end
20
-
21
- def call(state: "ready", action: nil, job_id: nil)
22
- queue = Textus::Ports::Queue.new(root: @container.root)
23
- case action
24
- when "retry" then queue.retry_failed(job_id)
25
- when "purge" then queue.purge(state)
26
- end
27
- { "protocol" => Textus::PROTOCOL, "ok" => true, "state" => state, "jobs" => queue.list(state) }
28
- end
29
- end
30
- end
31
- end
@@ -1,24 +0,0 @@
1
- module Textus
2
- module Read
3
- class List
4
- extend Textus::Contract::DSL
5
-
6
- verb :list
7
- summary "List keys filtered by zone and/or prefix."
8
- surfaces :cli, :mcp
9
- arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
10
- arg :zone, String, description: "restrict to one zone by name (see `boot` zones); combine with prefix to narrow further"
11
- view(:cli) { |rows| { "entries" => rows } }
12
-
13
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
14
- @manifest = container.manifest
15
- end
16
-
17
- def call(prefix: nil, zone: nil)
18
- rows = @manifest.resolver.enumerate(prefix: prefix)
19
- rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
20
- rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
21
- end
22
- end
23
- end
24
- end
@@ -1,22 +0,0 @@
1
- module Textus
2
- module Read
3
- class Published
4
- extend Textus::Contract::DSL
5
-
6
- verb :published
7
- summary "List all entries that declare a publish_to target."
8
- surfaces :cli
9
- cli "published"
10
-
11
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
12
- @manifest = container.manifest
13
- end
14
-
15
- def call
16
- @manifest.data.entries.reject { |e| e.publish_to.empty? }.map do |e|
17
- { "key" => e.key, "publish_to" => e.publish_to }
18
- end
19
- end
20
- end
21
- end
22
- end