textus 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -1,116 +0,0 @@
1
- require "json"
2
- require "optparse"
3
-
4
- module Textus
5
- class CLI
6
- # Subclasses must implement #call(store) and return an integer exit code.
7
- # Use #emit(obj) for normal JSON output (returns 0).
8
- # Subclasses that don't need a Textus store (e.g. Init) override
9
- # `.needs_store?` to return false; dispatch will pass nil instead.
10
- class Verb
11
- class << self
12
- def option(name, optspec)
13
- options << [name, optspec]
14
- attr_accessor(name)
15
- end
16
-
17
- def options
18
- @options ||= []
19
- end
20
-
21
- def needs_store?
22
- true
23
- end
24
-
25
- # Declarative CLI name. Reader returns the registered name (or nil
26
- # for verbs that aren't directly invokable, like the abstract
27
- # Verb/Group base classes). Writer registers it.
28
- def command_name(name = nil)
29
- if name.nil?
30
- @command_name
31
- else
32
- @command_name = name.to_s
33
- end
34
- end
35
-
36
- # Declares that this verb is a subcommand of `group_klass`. When
37
- # set, the verb is NOT a top-level CLI verb — it's listed under
38
- # the group's subcommands instead.
39
- def parent_group(group_klass = nil)
40
- if group_klass.nil?
41
- @parent_group
42
- else
43
- @parent_group = group_klass
44
- end
45
- end
46
-
47
- def inherited(subclass)
48
- super
49
- subclass.instance_variable_set(:@options, [])
50
- subclass.instance_variable_set(:@command_name, nil)
51
- subclass.instance_variable_set(:@parent_group, nil)
52
- end
53
-
54
- # Recursive subclass enumeration. Ruby 3.1 ships Class#subclasses
55
- # but not Class#descendants, so we expand it ourselves.
56
- def descendants
57
- subclasses.flat_map { |k| [k] + k.descendants }
58
- end
59
- end
60
-
61
- def initialize(stdin:, stdout:, stderr:, cwd: nil)
62
- @stdin = stdin
63
- @stdout = stdout
64
- @stderr = stderr
65
- @cwd = cwd
66
- end
67
-
68
- def parse(argv)
69
- fmt = "json"
70
- OptionParser.new do |o|
71
- self.class.options.each do |name, optspec|
72
- o.on(optspec) { |v| public_send(:"#{name}=", v) }
73
- end
74
- o.on("--output=FMT") { |v| fmt = v }
75
- o.on("--format=FMT") { |_v| raise FlagRenamed.new("--format", "--output") }
76
- end.permute!(argv)
77
- raise UsageError.new("only --output=json is supported in v1") unless fmt == "json"
78
-
79
- @positional = argv.dup
80
- end
81
-
82
- attr_reader :positional
83
-
84
- # Hashes get "protocol" => PROTOCOL prepended unless they already
85
- # carry one (Store envelopes do). Caller's value wins on collision.
86
- def emit(obj, exit_code: 0)
87
- payload = obj.is_a?(Hash) ? { "protocol" => PROTOCOL }.merge(obj) : obj
88
- @stdout.puts(JSON.generate(payload))
89
- exit_code
90
- end
91
-
92
- # Resolves the active role for this invocation. Honors the verb's
93
- # `--as` flag if declared, then TEXTUS_ROLE, then the project default.
94
- # Pass `default:` to override the fallback (e.g. MCPServe uses AGENT).
95
- def resolved_role(store, default: Role::DEFAULT)
96
- flag = respond_to?(:as_flag) ? as_flag : nil
97
- Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
98
- end
99
-
100
- # Returns a Call value bound to the resolved role. Convenience for
101
- # verbs whose only pre-call boilerplate is resolving the role and
102
- # wrapping it in a Call.
103
- def context_for(store)
104
- Textus::Call.build(role: resolved_role(store))
105
- end
106
-
107
- # Returns a RoleScope bound to the resolved role.
108
- def session_for(store)
109
- store.as(resolved_role(store))
110
- end
111
-
112
- # The input stream — the source for a `cli_stdin` envelope (ADR 0068).
113
- attr_reader :stdin
114
- end
115
- end
116
- end
data/lib/textus/cli.rb DELETED
@@ -1,138 +0,0 @@
1
- require "json"
2
- require "optparse"
3
-
4
- module Textus
5
- class CLI
6
- # Auto-derived verb table. Every CLI::Verb (or Group) subclass that
7
- # declares `command_name "X"` and has no `parent_group` is a top-level
8
- # verb. Sorted alphabetically for stable help output. Adding a new
9
- # verb requires only a new file declaring its `command_name`.
10
- #
11
- # `k.name` gates out anonymous (Class.new) subclasses: real verbs are always
12
- # named constants (generated Gen* or hand-authored classes), so this is a
13
- # no-op in production but keeps throwaway test fixtures from leaking into the
14
- # registry (and tripping the reconciliation guards order-dependently).
15
- def self.verbs
16
- Runner.install!
17
- Verb.descendants
18
- .select { |k| k.name && k.command_name && k.parent_group.nil? }
19
- .sort_by(&:command_name)
20
- .to_h { |k| [k.command_name, k] }
21
- end
22
-
23
- def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
24
- new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
25
- end
26
-
27
- def initialize(stdin:, stdout:, stderr:, cwd:)
28
- @stdin = stdin
29
- @stdout = stdout
30
- @stderr = stderr
31
- @cwd = cwd
32
- @root_arg = nil
33
- end
34
-
35
- def run(argv)
36
- # `--root` is a global, position-agnostic option: pull it out of argv
37
- # wherever it appears so it works uniformly before OR after any verb or
38
- # group (e.g. both `textus --root=X hook list` and
39
- # `textus hook list --root=X`). Without this, `order!` below only sees
40
- # options before the first verb token, so a trailing `--root` reached the
41
- # verb's own parser and raised InvalidOption (#161 F5). TEXTUS_ROOT already
42
- # works everywhere via Store.discover, so this brings the flag to parity.
43
- @root_arg = extract_root!(argv)
44
-
45
- # Define --version/--help ourselves so OptionParser doesn't intercept them
46
- # with its built-in handlers (which print "version unknown" and a bare usage
47
- # line, then exit before we ever reach the verb dispatch below).
48
- show_version = false
49
- show_help = false
50
- OptionParser.new do |o|
51
- o.on("--version", "-v") { show_version = true }
52
- o.on("--help", "-h") { show_help = true }
53
- end.order!(argv)
54
-
55
- return @stdout.puts(VERSION) || 0 if show_version
56
- return print_help || 0 if show_help
57
-
58
- verb = argv.shift
59
- raise UsageError.new("missing verb") if verb.nil?
60
-
61
- klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
62
- coerce_exit_code(dispatch(klass, argv))
63
- rescue Textus::Error => e
64
- emit_error(e)
65
- end
66
-
67
- private
68
-
69
- # Remove the first `--root=PATH` or `--root PATH` token from argv (anywhere)
70
- # and return its value, or nil if absent. Mutates argv in place.
71
- def extract_root!(argv)
72
- i = argv.index { |a| a == "--root" || a.start_with?("--root=") }
73
- return nil unless i
74
-
75
- tok = argv[i]
76
- if tok.start_with?("--root=")
77
- argv.delete_at(i)
78
- tok.delete_prefix("--root=")
79
- else
80
- val = argv[i + 1]
81
- raise UsageError.new("--root requires a PATH") if val.nil? || val.start_with?("-")
82
-
83
- argv.delete_at(i + 1)
84
- argv.delete_at(i)
85
- val
86
- end
87
- end
88
-
89
- def coerce_exit_code(value)
90
- case value
91
- when Integer then value
92
- when true, nil then 0
93
- when false then 1
94
- else
95
- @stderr.puts("warning: verb returned non-Integer #{value.class}; treating as 0")
96
- 0
97
- end
98
- end
99
-
100
- def store
101
- @store ||= Store.discover(@cwd, root: @root_arg)
102
- end
103
-
104
- def dispatch(klass, argv)
105
- v = klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
106
- v.parse(argv)
107
- v.call(klass.needs_store? ? store : nil)
108
- end
109
-
110
- def emit_error(err)
111
- @stdout.puts(JSON.generate(err.to_envelope))
112
- @stderr.puts("#{err.code}: #{err.message}")
113
- @stderr.puts(" → #{err.hint}") if err.hint
114
- err.exit_code
115
- end
116
-
117
- def print_help
118
- @stdout.puts <<~HELP
119
- textus #{VERSION} — reference implementation of #{PROTOCOL}
120
-
121
- Usage (json output is the default):
122
- textus list [--prefix=KEY] [--zone=Z]
123
- textus where KEY
124
- textus get KEY
125
- textus put KEY --stdin --as=ROLE
126
- textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
127
- textus blame KEY [--limit=N]
128
- textus doctor
129
- textus boot
130
-
131
- textus key {delete,mv,uid}
132
- textus rule {explain,lint,list}
133
- textus schema {diff,init,migrate,show}
134
- textus hook {list,run}
135
- HELP
136
- end
137
- end
138
- end
@@ -1,54 +0,0 @@
1
- module Textus
2
- # Static verb → use-case map. Canonical lookup as of 0.27.0; replaces the
3
- # Application::UseCase registry whose entries were populated by file-load
4
- # side effects in 0.26.x.
5
- module Dispatcher
6
- VERBS = {
7
- # Write
8
- put: Textus::Write::Put,
9
- propose: Textus::Write::Propose,
10
- key_delete: Textus::Write::KeyDelete,
11
- key_mv: Textus::Write::KeyMv,
12
- accept: Textus::Write::Accept,
13
- reject: Textus::Write::Reject,
14
- enqueue: Textus::Write::Enqueue,
15
- # Read
16
- get: Textus::Read::Get,
17
- list: Textus::Read::List,
18
- where: Textus::Read::Where,
19
- uid: Textus::Read::Uid,
20
- blame: Textus::Read::Blame,
21
- audit: Textus::Read::Audit,
22
- freshness: Textus::Read::Freshness,
23
- deps: Textus::Read::Deps,
24
- rdeps: Textus::Read::Rdeps,
25
- pulse: Textus::Read::Pulse,
26
- rule_explain: Textus::Read::RuleExplain,
27
- rule_list: Textus::Read::RuleList,
28
- published: Textus::Read::Published,
29
- schema_show: Textus::Read::SchemaEnvelope,
30
- validate_all: Textus::Read::ValidateAll,
31
- doctor: Textus::Read::Doctor,
32
- boot: Textus::Read::Boot,
33
- capabilities: Textus::Read::Capabilities,
34
- jobs: Textus::Read::Jobs,
35
-
36
- # Maintenance
37
- zone_mv: Textus::Maintenance::ZoneMv,
38
- key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
39
- key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
40
- drain: Textus::Maintenance::Drain,
41
- rule_lint: Textus::Maintenance::RuleLint,
42
- }.freeze
43
-
44
- def self.fetch(verb)
45
- VERBS.fetch(verb.to_sym) { raise UsageError.new("unknown verb: #{verb.inspect}") }
46
- end
47
-
48
- # Single home for the uniform use-case invocation protocol (ADR 0023):
49
- # look up the verb, construct on (container:, call:), and invoke #call.
50
- def self.invoke(verb, container:, call:, args: [], kwargs: {})
51
- fetch(verb).new(container: container, call: call).call(*args, **kwargs)
52
- end
53
- end
54
- end
@@ -1,34 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- # For every entry with an `intake.handler`, look up its handler_allowlist
5
- # policy (if any) and verify the declared handler is allowed. Emits a
6
- # failure when the handler is rejected by policy.
7
- class HandlerAllowlist < Check
8
- def call
9
- out = []
10
- manifest.data.entries.each do |mentry|
11
- next unless mentry.intake?
12
-
13
- handler = mentry.handler
14
-
15
- allow = manifest.rules.for(mentry.key).handler_allowlist
16
- next if allow.nil?
17
- next if allow.allows?(handler)
18
-
19
- out << {
20
- "code" => "policy.handler_not_allowed",
21
- "level" => "error",
22
- "subject" => mentry.key,
23
- "message" => "entry '#{mentry.key}' declares intake.handler='#{handler}' but the " \
24
- "handler_allowlist policy permits only: #{allow.handlers.join(", ")}",
25
- "fix" => "either change intake.handler to one of [#{allow.handlers.join(", ")}], " \
26
- "or extend the handler_allowlist policy in .textus/manifest.yaml",
27
- }
28
- end
29
- out
30
- end
31
- end
32
- end
33
- end
34
- end
@@ -1,32 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- class Hooks < Check
5
- def call
6
- out = []
7
- dir = File.join(root, "hooks")
8
- return out unless File.directory?(dir)
9
-
10
- Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
11
- events = Textus::Hooks::EventBus.new
12
- rpc = Textus::Hooks::RpcRegistry.new
13
- Textus.drain_hook_blocks
14
- begin
15
- load(f)
16
- Textus.drain_hook_blocks.each { |b| b.call(Textus::Hooks::Loader::Dsl.new(events: events, rpc: rpc)) }
17
- end
18
- rescue StandardError, ScriptError => e
19
- out << {
20
- "code" => "hook.load_failed",
21
- "level" => "error",
22
- "subject" => File.basename(f),
23
- "message" => "#{e.class}: #{e.message}",
24
- "fix" => "open #{f} and fix the syntax/load error",
25
- }
26
- end
27
- out
28
- end
29
- end
30
- end
31
- end
32
- end
@@ -1,46 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- class IntakeRegistration < Check
5
- BUILTIN = %i[json csv markdown-links ical-events rss].freeze
6
-
7
- def call
8
- declared = collect_declared_handlers
9
- registered = rpc.names(:resolve_handler).to_set
10
-
11
- out = (declared - registered).map do |name|
12
- {
13
- "code" => "intake.handler_missing",
14
- "level" => "error",
15
- "subject" => name.to_s,
16
- "message" => "manifest references intake handler '#{name}' but no resolve_handler hook for '#{name}' is registered",
17
- "fix" => "create .textus/hooks/#{name}.rb with `Textus.hook { |reg| reg.on(:resolve_handler, :#{name}) { ... } }`",
18
- }
19
- end
20
-
21
- (registered - declared - BUILTIN.to_set).each do |name|
22
- out << {
23
- "code" => "intake.handler_orphan",
24
- "level" => "warning",
25
- "subject" => name.to_s,
26
- "message" => "resolve_handler hook '#{name}' is registered but no manifest entry references it",
27
- "fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
28
- }
29
- end
30
-
31
- out
32
- end
33
-
34
- private
35
-
36
- def collect_declared_handlers
37
- set = Set.new
38
- manifest.data.entries.each do |mentry|
39
- set << mentry.handler.to_sym if mentry.intake?
40
- end
41
- set
42
- end
43
- end
44
- end
45
- end
46
- end
@@ -1,9 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Action
4
- Return = Data.define
5
- FetchSync = Data.define
6
- FetchTimed = Data.define(:budget_ms)
7
- end
8
- end
9
- end
@@ -1,37 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Jobs
4
- # Closed allow-list of runnable job types. The general `enqueue` surface
5
- # (a later phase) can only push types registered here — that is the safety
6
- # boundary that stops the "general runner" from running arbitrary code.
7
- class Registry
8
- Entry = Struct.new(:handler, :max_attempts, :required_role, keyword_init: true)
9
-
10
- def initialize
11
- @entries = {}
12
- end
13
-
14
- # required_role: a role the caller must hold to enqueue this type via the
15
- # general `enqueue` surface (nil = any caller). The closed allow-list is
16
- # the primary safety boundary; this is defence-in-depth for destructive
17
- # types.
18
- def register(type, handler:, max_attempts: 3, required_role: nil)
19
- @entries[type.to_s] = Entry.new(handler: handler, max_attempts: max_attempts, required_role: required_role)
20
- end
21
-
22
- def registered?(type)
23
- @entries.key?(type.to_s)
24
- end
25
-
26
- def lookup(type)
27
- @entries.fetch(type.to_s) do
28
- raise Textus::UsageError.new(
29
- "unregistered job type '#{type}'",
30
- hint: "register the type in Domain::Jobs::Registry before enqueueing it",
31
- )
32
- end
33
- end
34
- end
35
- end
36
- end
37
- end
@@ -1,7 +0,0 @@
1
- module Textus
2
- module Domain
3
- Permission = Data.define(:zone, :writers) do
4
- def allows_write?(role) = writers.include?(role.to_s)
5
- end
6
- end
7
- end
@@ -1,25 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- # The CLOSED floor (ADR 0031 §4): predicate names every transition
7
- # evaluates regardless of rules:. rules[].guard only ADDS to these.
8
- module BaseGuards
9
- # The minimal floor — only what the verb is meaningless without.
10
- # schema_valid / etag_match / fresh_within are NOT here: they are
11
- # composable-only, added per-key via rules[].guard (ADR 0031).
12
- BASE = {
13
- put: %w[zone_writable_by],
14
- key_delete: %w[zone_writable_by],
15
- key_mv: %w[zone_writable_by],
16
- accept: %w[author_held target_is_canon],
17
- reject: %w[author_held],
18
- converge: %w[zone_writable_by],
19
- }.freeze
20
-
21
- def self.for(transition) = BASE.fetch(transition, [])
22
- end
23
- end
24
- end
25
- end
@@ -1,15 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- # Immutable context handed to every predicate. `manifest` is the
7
- # manifest (pure, no I/O); `envelope` is the entry under evaluation
8
- # (nil when no bytes exist yet, e.g. a fresh put). `origin`/`target`
9
- # are dotted keys; `transition` is the verb symbol.
10
- Evaluation = Data.define(
11
- :actor, :transition, :origin, :target, :envelope, :manifest
12
- )
13
- end
14
- end
15
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- # An ordered list of pure predicates over one Evaluation (ADR 0031).
7
- # check! short-circuits on the first failing predicate that defines a
8
- # bespoke #error (only zone_writable_by → WriteForbidden, the product's
9
- # legible topology refusal); every other failure accumulates into
10
- # GuardFailed naming the unmet predicate(s).
11
- class Guard
12
- attr_reader :predicates
13
-
14
- def initialize(predicates)
15
- @predicates = predicates
16
- end
17
-
18
- def check!(eval)
19
- accumulated = []
20
- @predicates.each do |pred|
21
- next if pred.call(eval)
22
- raise pred.error(eval) if pred.respond_to?(:error)
23
-
24
- accumulated << [pred.name, pred.reason]
25
- end
26
- raise Textus::GuardFailed.new(accumulated) unless accumulated.empty?
27
- end
28
-
29
- def explain(eval)
30
- @predicates.map { |p| [p.name, p.call(eval), p.reason] }
31
- end
32
- end
33
- end
34
- end
35
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- # Builds the effective Guard for (transition, key): base floor ++
7
- # the predicates declared under rules[].guard[transition]. The single
8
- # place the closed floor and the open ceiling are composed.
9
- class GuardFactory
10
- def initialize(manifest:, schemas:, extra: {})
11
- @manifest = manifest
12
- @schemas = schemas
13
- @extra = extra # transient per-call params, e.g. { if_etag: "..." }
14
- end
15
-
16
- def for(transition, key)
17
- specs = BaseGuards.for(transition) + composed(transition, key)
18
- predicates = specs.map { |spec| build(spec) }.uniq(&:name)
19
- Guard.new(predicates)
20
- end
21
-
22
- private
23
-
24
- def composed(transition, key)
25
- guard_map = @manifest.rules.for(key).guard
26
- return [] if guard_map.nil?
27
-
28
- Array(guard_map[transition.to_s])
29
- end
30
-
31
- def build(spec)
32
- # etag_match takes a per-call param rather than a manifest one.
33
- return Predicates::EtagMatch.new(if_etag: @extra[:if_etag]) if spec == "etag_match"
34
-
35
- Predicates::Registry.build(spec, schemas: @schemas)
36
- end
37
- end
38
- end
39
- end
40
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- class HandlerAllowlist
5
- attr_reader :handlers
6
-
7
- def initialize(handlers:)
8
- @handlers = Array(handlers).map(&:to_s).freeze
9
- end
10
-
11
- def allows?(handler)
12
- @handlers.include?(handler.to_s)
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- module Predicates
7
- # Predicate: the acting role must hold the 'author' capability in the
8
- # active manifest (ADR 0030 capability roles). Folds in the old
9
- # Write::AuthorityGate so accept/reject and rules[].guard share one
10
- # implementation. No bespoke #error — failures accumulate into
11
- # GuardFailed (ADR 0031).
12
- class AuthorHeld
13
- attr_reader :reason
14
-
15
- def name = "author_held"
16
-
17
- def call(eval)
18
- holders = eval.manifest.policy.roles_with_capability("author")
19
- return true if holders.include?(eval.actor.to_s)
20
-
21
- @reason =
22
- if holders.empty?
23
- "no role holds the 'author' capability; #{eval.transition} is disabled"
24
- else
25
- "role '#{eval.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
26
- end
27
- false
28
- end
29
- end
30
- end
31
- end
32
- end
33
- end
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- module Predicates
7
- # Advisory pre-flight etag check for policy explain. The
8
- # authoritative compare-and-write stays in Envelope::IO::Writer
9
- # (atomic write-then-audit, ADR 0017). Passes when no if_etag is
10
- # supplied (params[:if_etag] nil) — guard does not require it.
11
- class EtagMatch
12
- attr_reader :reason
13
-
14
- def initialize(if_etag: nil)
15
- @if_etag = if_etag
16
- end
17
-
18
- def name = "etag_match"
19
-
20
- def call(eval)
21
- return true if @if_etag.nil?
22
- return true if eval.envelope.nil? # creating; Writer handles race
23
- return true if eval.envelope.etag == @if_etag
24
-
25
- @reason = "etag mismatch: wanted #{@if_etag}, have #{eval.envelope.etag}"
26
- false
27
- end
28
- end
29
- end
30
- end
31
- end
32
- end