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,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
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Read
3
- # Store-wide schema + role-authority validation: walks every entry and runs
4
- # the Validator over it. Consumed internally by `doctor`'s schema_violations
5
- # check and exposed as a Ruby store method (`store.validate_all`).
6
- #
7
- # Ruby-only, like `Read::Freshness`: it declares a contract (so it round-trips
8
- # through the routing<->contract bijection, ADR 0105) but omits `surfaces`, so
9
- # it gets no CLI or MCP projection. The public `validate-all` CLI verb was
10
- # removed in v0.5 (`doctor --check=schema_violations` replaces it).
11
- class ValidateAll
12
- extend Textus::Contract::DSL
13
-
14
- verb :validate_all
15
- summary "Internal store-wide schema + role-authority validation; backs " \
16
- "doctor's schema_violations check. No public surface (ADR 0105)."
17
-
18
- def initialize(container:, call:)
19
- @container = container
20
- @call = call
21
- @manifest = container.manifest
22
- @schemas = container.schemas
23
- @audit_log = container.audit_log
24
- end
25
-
26
- def call
27
- Validator.new(
28
- reader: Get.new(container: @container, call: @call),
29
- manifest: @manifest,
30
- audit_log: @audit_log,
31
- schema_for: ->(name) { @schemas.fetch_or_nil(name) },
32
- ).call
33
- end
34
- end
35
- end
36
- end
@@ -1,24 +0,0 @@
1
- module Textus
2
- module Read
3
- class Where
4
- extend Textus::Contract::DSL
5
-
6
- verb :where
7
- summary "Resolve a key to its zone, owner, and path without reading the body."
8
- surfaces :cli, :mcp
9
- arg :key, String, required: true, positional: true,
10
- description: "dotted key to locate (returns zone, owner, path; does not read content)"
11
-
12
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
13
- @manifest = container.manifest
14
- end
15
-
16
- def call(key)
17
- res = @manifest.resolver.resolve(key)
18
- mentry = res.entry
19
- path = res.path
20
- { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
21
- end
22
- end
23
- end
24
- end
@@ -1,78 +0,0 @@
1
- module Textus
2
- # Thin role-scoped facade over a Container. Closes over a role default
3
- # and a dry_run flag, then forwards every verb in Dispatcher::VERBS to
4
- # the corresponding use case.
5
- #
6
- # Replaces the per-call Session under the 0.27.0 architecture: a Store
7
- # exposes #as(role) to get a RoleScope, and Store#put / Store#get / etc
8
- # delegate to RoleScope under the default role.
9
- class RoleScope
10
- attr_reader :container, :role, :correlation_id
11
-
12
- def dry_run?
13
- @dry_run
14
- end
15
-
16
- def initialize(container:, role:, dry_run: false, correlation_id: nil)
17
- @container = container
18
- @role = role.to_s
19
- @dry_run = dry_run
20
- @correlation_id = correlation_id
21
- end
22
-
23
- def with_role(role)
24
- self.class.new(container: @container, role: role, dry_run: @dry_run, correlation_id: @correlation_id)
25
- end
26
-
27
- def with_correlation_id(cid)
28
- self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
29
- end
30
-
31
- def hook_context
32
- @hook_context ||= Textus::Hooks::Context.new(scope: self)
33
- end
34
-
35
- def with_dry_run
36
- self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
37
- end
38
-
39
- # Single bind + invoke site for every surface. `inputs` is the uniform
40
- # by-name hash (the binder's currency). MCP/CLI build it from their raw
41
- # transport shape and call this directly; the per-verb Ruby methods below
42
- # normalize positional+keyword Ruby args into `inputs` and delegate here.
43
- def dispatch_bound(verb, inputs, session: nil)
44
- klass = Textus::Dispatcher::VERBS[verb]
45
- spec = (klass.contract if klass.respond_to?(:contract?) && klass.contract?)
46
-
47
- invoke = lambda do |effective_inputs|
48
- args, kwargs =
49
- if spec
50
- Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
51
- else
52
- [[], effective_inputs]
53
- end
54
- call_value = Textus::Call.build(role: @role, correlation_id: @correlation_id, dry_run: @dry_run)
55
- Textus::Dispatcher.invoke(verb, container: @container, call: call_value, args: args, kwargs: kwargs)
56
- end
57
-
58
- if spec&.around
59
- Textus::Contract::Around.with(spec.around, scope: self, inputs: inputs, session: session, &invoke)
60
- else
61
- invoke.call(inputs)
62
- end
63
- end
64
-
65
- Textus::Dispatcher::VERBS.each_key do |verb|
66
- define_method(verb) do |*args, **kwargs|
67
- klass = Textus::Dispatcher::VERBS[verb]
68
- inputs =
69
- if klass.respond_to?(:contract?) && klass.contract?
70
- Textus::Contract::Binder.inputs_from_ordered(klass.contract, args, kwargs)
71
- else
72
- kwargs
73
- end
74
- dispatch_bound(verb, inputs)
75
- end
76
- end
77
- end
78
- end
@@ -1,58 +0,0 @@
1
- module Textus
2
- module Write
3
- class Accept
4
- extend Textus::Contract::DSL
5
-
6
- verb :accept
7
- summary "apply a queued proposal to its target zone; requires the author capability"
8
- surfaces :cli, :mcp
9
- cli "accept"
10
- arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
11
-
12
- def initialize(container:, call:)
13
- @container = container
14
- @call = call
15
- @manifest = container.manifest
16
- @schemas = container.schemas
17
- @events = container.events
18
- end
19
-
20
- def call(pending_key)
21
- env = Textus::Read::Get.new(container: @container, call: @call).call(pending_key)
22
- proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
23
- target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
24
- action = proposal["action"] || "put"
25
-
26
- guard.for(:accept, target).check!(
27
- Textus::Domain::Policy::Evaluation.new(
28
- actor: @call.role, transition: :accept, origin: pending_key,
29
- target: target, envelope: env, manifest: @manifest
30
- ),
31
- )
32
-
33
- case action
34
- when "put"
35
- put_op.call(target, meta: env.meta["frontmatter"] || {}, body: env.body)
36
- when "delete"
37
- delete_op.call(target)
38
- else
39
- raise ProposalError.new("unknown action: #{action}")
40
- end
41
-
42
- delete_op.call(pending_key)
43
- @events.publish(:proposal_accepted, ctx: hook_context, key: pending_key, target_key: target)
44
- { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
45
- end
46
-
47
- private
48
-
49
- def guard
50
- @guard ||= Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
51
- end
52
-
53
- def hook_context = @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
54
- def put_op = @put_op ||= Textus::Write::Put.new(container: @container, call: @call)
55
- def delete_op = @delete_op ||= Textus::Write::KeyDelete.new(container: @container, call: @call)
56
- end
57
- end
58
- end
@@ -1,50 +0,0 @@
1
- module Textus
2
- module Write
3
- # Push a job of a REGISTERED type onto the convergence queue, to be run by
4
- # drain/serve. The closed allow-list (Jobs::Handlers.registry) is the safety
5
- # boundary: an unregistered type is refused, so the general runner can never
6
- # execute arbitrary code. Authority is checked here (the caller must hold the
7
- # type's required_role, if any) and frozen onto the job's `enqueued_by` — the
8
- # worker runs it as exactly this role, no escalation via the queue.
9
- class Enqueue
10
- extend Textus::Contract::DSL
11
-
12
- verb :enqueue
13
- summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
14
- surfaces :cli, :mcp
15
- cli "enqueue"
16
- arg :type, String, required: true, positional: true, description: "registered job type (e.g. materialize, re-pull, sweep)"
17
- arg :args, Hash, positional: true, default: {}, description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
18
-
19
- def initialize(container:, call:)
20
- @container = container
21
- @call = call
22
- end
23
-
24
- def call(type, args = {})
25
- entry = Textus::Jobs::Handlers.registry.lookup(type) # raises UsageError for unregistered types
26
- authorize!(entry)
27
-
28
- job = Textus::Domain::Jobs::Job.new(
29
- type: type, args: args, enqueued_by: @call.role, max_attempts: entry.max_attempts,
30
- )
31
- Textus::Ports::Queue.new(root: @container.root).enqueue(job)
32
- { "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
33
- end
34
-
35
- private
36
-
37
- def authorize!(entry)
38
- required = entry.required_role
39
- return if required.nil? || @call.role == required
40
-
41
- raise Textus::Error.new(
42
- "forbidden",
43
- "role '#{@call.role}' is not authorized to enqueue this job type (requires '#{required}')",
44
- details: { "role" => @call.role, "required_role" => required },
45
- exit_code: 77,
46
- )
47
- end
48
- end
49
- end
50
- end
@@ -1,65 +0,0 @@
1
- module Textus
2
- module Write
3
- class KeyDelete
4
- extend Textus::Contract::DSL
5
-
6
- verb :key_delete
7
- summary "Delete one entry by key. Single-key, lower blast radius than " \
8
- "key_delete_prefix; guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
9
- surfaces :cli, :mcp
10
- cli "key delete"
11
- arg :key, String, required: true, positional: true,
12
- description: "dotted entry key to delete"
13
- arg :if_etag, String,
14
- description: "optimistic-concurrency guard: the etag you last read; the delete is rejected if the entry changed since"
15
- # `call` already returns a wire hash {protocol, ok, key, deleted}; identity response.
16
-
17
- def initialize(container:, call:)
18
- @container = container
19
- @call = call
20
- @manifest = container.manifest
21
- @events = container.events
22
- end
23
-
24
- def call(key, if_etag: nil, suppress_events: false)
25
- Textus::Manifest::Data.validate_key!(key)
26
- mentry = @manifest.resolver.resolve(key).entry
27
-
28
- guard_for(:key_delete, key, if_etag: if_etag).check!(eval_for(:key_delete, target_key: key))
29
-
30
- writer.delete(key, mentry: mentry, if_etag: if_etag)
31
-
32
- unless suppress_events
33
- @events.publish(:entry_deleted,
34
- ctx: hook_context,
35
- key: key)
36
- end
37
-
38
- { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
39
- end
40
-
41
- private
42
-
43
- def guard_for(transition, key, if_etag: nil)
44
- Textus::Domain::Policy::GuardFactory.new(
45
- manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
46
- ).for(transition, key)
47
- end
48
-
49
- def eval_for(transition, target_key:, envelope: nil)
50
- Textus::Domain::Policy::Evaluation.new(
51
- actor: @call.role, transition: transition, origin: nil,
52
- target: target_key, envelope: envelope, manifest: @manifest
53
- )
54
- end
55
-
56
- def hook_context
57
- @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
58
- end
59
-
60
- def writer
61
- @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
62
- end
63
- end
64
- end
65
- end