textus 0.51.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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  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 +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  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/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
@@ -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 `reconcile --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
- # `reconcile --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 `reconcile` (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 `reconcile` 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 reconcile + 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,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
@@ -1,98 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Read
5
- # Aggregator over audit + freshness + review + doctor. One round-trip
6
- # for an agent's per-turn heartbeat. All component reads are existing
7
- # APIs; pulse is sugar with a stable envelope shape and a monotonic
8
- # cursor (seq).
9
- class Pulse
10
- extend Textus::Contract::DSL
11
-
12
- verb :pulse
13
- summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
14
- surfaces :cli, :mcp
15
- around :cursor
16
- arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
17
-
18
- def initialize(container:, call:)
19
- @container = container
20
- @call = call
21
- @manifest = container.manifest
22
- @file_store = container.file_store
23
- @audit_log = container.audit_log
24
- @root = container.root
25
- @events = container.events
26
- end
27
-
28
- def call(since: 0)
29
- freshness_rows = freshness.call
30
- {
31
- "cursor" => @audit_log.latest_seq,
32
- "changed" => audit_changes_since(since),
33
- "stale" => freshness_rows.select { |r| r[:status] == :expired }.map { |r| r[:key] },
34
- "pending_review" => review_keys,
35
- "doctor" => doctor_summary,
36
- "contract_etag" => contract_etag,
37
- "next_due_at" => soonest_due(freshness_rows),
38
- "hook_errors" => hook_errors_since(since),
39
- }
40
- end
41
-
42
- private
43
-
44
- def audit_changes_since(seq)
45
- Read::Audit.new(container: @container).call(seq_since: seq)
46
- end
47
-
48
- def freshness
49
- @freshness ||= Read::Freshness.new(container: @container, call: @call)
50
- end
51
-
52
- def soonest_due(rows)
53
- times = rows.map { |r| r[:next_due_at] }.compact.map { |t| Time.parse(t) }
54
- return nil if times.empty?
55
-
56
- times.min.utc.iso8601
57
- end
58
-
59
- def review_keys
60
- # The single queue zone (kind: queue; schema guarantees ≤1), derived
61
- # from the manifest rather than a hardcoded zone name (ADR 0034 / D1).
62
- queue = @manifest.policy.queue_zone
63
- return [] unless queue
64
-
65
- rows = Read::List.new(container: @container).call(zone: queue)
66
- rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
67
- end
68
-
69
- def doctor_summary
70
- result = Textus::Doctor.build(container: @container)
71
- issues = result["issues"] || []
72
- {
73
- "ok" => result["ok"],
74
- "warn" => issues.count { |i| i["level"] == "warning" },
75
- "fail" => issues.count { |i| i["level"] == "error" },
76
- }
77
- end
78
-
79
- def contract_etag
80
- Textus::Etag.for_contract(@root)
81
- end
82
-
83
- def hook_errors_since(seq)
84
- @events.error_log.since(seq).map do |r|
85
- {
86
- "seq" => r[:seq],
87
- "event" => r[:event].to_s,
88
- "hook" => r[:hook].to_s,
89
- "key" => r[:key],
90
- "error_class" => r[:error_class],
91
- "error_message" => r[:error_message],
92
- "at" => r[:at],
93
- }
94
- end
95
- end
96
- end
97
- end
98
- end
@@ -1,39 +0,0 @@
1
- module Textus
2
- module Read
3
- class Rdeps
4
- extend Textus::Contract::DSL
5
-
6
- verb :rdeps
7
- summary "List the derived entries that depend on a key (reverse deps / impact set)."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted key whose dependents (what would be stranded if it moved) 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, "rdeps" => dependents_of(key) }
18
- end
19
-
20
- private
21
-
22
- def dependents_of(key)
23
- @manifest.data.entries.each_with_object([]) do |e, acc|
24
- next unless e.derived?
25
-
26
- src = e.source
27
- sources = if src.projection?
28
- Array(src.select).compact
29
- elsif src.external?
30
- Array(src.sources).compact
31
- else
32
- []
33
- end
34
- acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,96 +0,0 @@
1
- module Textus
2
- module Read
3
- # Effective rules for a key, at two depths (ADR 0059). Lean by default —
4
- # `{ lifecycle, guard }`, the agent-cheap read that was the `rules` verb. With
5
- # `detail: true` it returns the verbose explanation — every matching policy
6
- # block plus the per-transition guard predicate names — that was
7
- # `policy_explain`. One verb, one name across CLI/MCP/method; the audience
8
- # split is a parameter, not two tools.
9
- class RuleExplain
10
- extend Textus::Contract::DSL
11
-
12
- verb :rule_explain
13
- summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
14
- surfaces :cli, :mcp
15
- cli "rule explain"
16
- arg :key, String, required: true, positional: true,
17
- description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
18
- arg :detail, :boolean,
19
- description: "defaults false: lean {lifecycle, guard}. detail: true adds matched blocks + guard predicates per transition."
20
- view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
21
-
22
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
23
- @manifest = container.manifest
24
- @schemas = container.schemas
25
- end
26
-
27
- REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
28
- # Field membership is registry-driven (WS3). Lean shows the fields tagged
29
- # for :lean; detail's matched_blocks flag every :detail field. The
30
- # `effective` value-block shows the instantiated-policy fields (those with
31
- # a policy_class) — guard, being a raw deferred field, is surfaced through
32
- # the dedicated `guards:` predicate section instead.
33
- LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
34
- DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
35
- EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
36
-
37
- def call(key, detail: false)
38
- detail ? explain(key) : effective(key)
39
- end
40
-
41
- private
42
-
43
- # Lean: the effective winners only (formerly Read::Rules / the `rules` verb).
44
- def effective(key)
45
- set = @manifest.rules.for(key)
46
- LEAN_FIELDS.each_with_object({}) do |field, out|
47
- value = set.public_send(field)
48
- out[field.to_s] = lean_value(field, value) unless value.nil?
49
- end
50
- end
51
-
52
- def lean_value(field, value)
53
- case field
54
- when :retention then retention_hash(value, string_keys: true)
55
- else value
56
- end
57
- end
58
-
59
- # Verbose: every matching block, per-slot effective value, and the
60
- # effective guard predicate names for each write transition (formerly
61
- # Read::PolicyExplain, ADR 0031).
62
- def explain(key)
63
- matching = @manifest.rules.explain(key)
64
- winners = @manifest.rules.for(key)
65
- factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
66
-
67
- {
68
- key: key,
69
- matched_blocks: matching.map do |b|
70
- { match: b.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !b.public_send(f).nil?] })
71
- end,
72
- effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
73
- guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
74
- [transition, factory.for(transition, key).predicates.map(&:name)]
75
- end,
76
- }
77
- end
78
-
79
- def effective_value(field, value)
80
- return nil if value.nil?
81
-
82
- case field
83
- when :retention then retention_hash(value, string_keys: false)
84
- when :handler_allowlist then value.handlers
85
- else value
86
- end
87
- end
88
-
89
- # ADR 0093: retention is a flat GC policy (ttl + drop/archive action).
90
- def retention_hash(retention, string_keys:)
91
- h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
92
- string_keys ? h.transform_keys(&:to_s) : h
93
- end
94
- end
95
- end
96
- end
@@ -1,54 +0,0 @@
1
- module Textus
2
- module Read
3
- # Enumerate every declared rule block in the manifest, in order. This is
4
- # the whole-manifest view; `rule_explain` is the for-key view. Extracted
5
- # from the CLI verb so the rule family is fully use-case-backed (ADR 0059);
6
- # CLI-only (no MCP contract) — an agent reasons per-key via rule_explain.
7
- class RuleList
8
- extend Textus::Contract::DSL
9
-
10
- verb :rule_list
11
- summary "List every rule block in the manifest."
12
- surfaces :cli
13
- cli "rule list"
14
- view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
15
-
16
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
17
- @manifest = container.manifest
18
- end
19
-
20
- # Fields shown here are driven by FIELD_REGISTRY (in_rule_list); only the
21
- # per-field serialization below is field-specific.
22
- LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
23
-
24
- def call
25
- @manifest.rules.blocks.map do |b|
26
- row = { "match" => b.match }
27
- LIST_FIELDS.each do |field|
28
- value = b.public_send(field)
29
- row[field.to_s] = serialize(field, value) unless value.nil?
30
- end
31
- row
32
- end
33
- end
34
-
35
- private
36
-
37
- def serialize(field, value)
38
- case field
39
- when :retention
40
- serialize_retention(value)
41
- when :handler_allowlist
42
- value.handlers
43
- else
44
- value
45
- end
46
- end
47
-
48
- # ADR 0093: retention is a flat GC policy.
49
- def serialize_retention(retention)
50
- { "ttl_seconds" => retention.ttl_seconds, "action" => retention.action.to_s }
51
- end
52
- end
53
- end
54
- end
@@ -1,25 +0,0 @@
1
- module Textus
2
- module Read
3
- class SchemaEnvelope
4
- extend Textus::Contract::DSL
5
-
6
- verb :schema_show
7
- summary "Return the schema (field shape) for an entry's family, by key."
8
- surfaces :cli, :mcp
9
- cli "schema show"
10
- arg :key, String, required: true, positional: true,
11
- description: "any key in the family whose schema you want; returns required/optional fields and their types"
12
-
13
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
14
- @manifest = container.manifest
15
- @schemas = container.schemas
16
- end
17
-
18
- def call(key)
19
- mentry = @manifest.resolver.resolve(key).entry
20
- schema = @schemas.fetch_or_nil(mentry.schema)
21
- { "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
22
- end
23
- end
24
- end
25
- end
@@ -1,29 +0,0 @@
1
- module Textus
2
- module Read
3
- class Uid
4
- extend Textus::Contract::DSL
5
-
6
- verb :uid
7
- summary "Return the stable UID of an entry without reading its body."
8
- surfaces :cli
9
- cli "key uid"
10
- arg :key, String, required: true, positional: true, description: "entry key"
11
- view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
12
-
13
- def initialize(container:, call:)
14
- @container = container
15
- @call = call
16
- end
17
-
18
- def call(key)
19
- get.get(key).uid
20
- end
21
-
22
- private
23
-
24
- def get
25
- @get ||= Get.new(container: @container, call: @call)
26
- end
27
- end
28
- end
29
- end