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
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Textus
6
+ module Action
7
+ class RuleLint < Base
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :rule_lint
11
+ summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
12
+ surfaces :cli, :mcp
13
+ cli "rule lint"
14
+ arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
15
+ description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
16
+ view { |v, _i| v.to_h }
17
+
18
+ def initialize(candidate_yaml:)
19
+ super()
20
+ @candidate_yaml = candidate_yaml
21
+ end
22
+
23
+ def call(container:, **)
24
+ root = container.root
25
+ live_rules = current_rules(root)
26
+ candidate_rules = parse_candidate(@candidate_yaml)
27
+
28
+ live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
29
+ candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
30
+
31
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
32
+ { "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
33
+ end
34
+ (live_by_match.keys - candidate_by_match.keys).each do |match|
35
+ steps << { "op" => "remove_rule", "match" => match }
36
+ end
37
+ (live_by_match.keys & candidate_by_match.keys).each do |match|
38
+ next if live_by_match[match] == candidate_by_match[match]
39
+
40
+ steps << {
41
+ "op" => "change_rule",
42
+ "match" => match,
43
+ "from" => live_by_match[match],
44
+ "to" => candidate_by_match[match],
45
+ }
46
+ end
47
+
48
+ Textus::Jobs::Plan.new(steps: steps, warnings: [])
49
+ end
50
+
51
+ private
52
+
53
+ def current_rules(root)
54
+ raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
55
+ Array(raw["rules"])
56
+ end
57
+
58
+ def parse_candidate(yaml_text)
59
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
60
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
61
+
62
+ Array(raw["rules"])
63
+ rescue Psych::Exception => e
64
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class RuleList < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :rule_list
9
+ summary "List every rule block in the manifest."
10
+ surfaces :cli
11
+ cli "rule list"
12
+ view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
13
+
14
+ def call(container:, **)
15
+ manifest = container.manifest
16
+ manifest.rules.blocks.map do |block|
17
+ row = { "match" => block.match }
18
+ self.class::LIST_FIELDS.each do |field|
19
+ value = block.public_send(field)
20
+ row[field.to_s] = serialize(field, value) unless value.nil?
21
+ end
22
+ row
23
+ end
24
+ end
25
+
26
+ LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
27
+
28
+ private
29
+
30
+ def serialize(field, value)
31
+ case field
32
+ when :retention
33
+ { "ttl_seconds" => value.ttl_seconds, "action" => value.action.to_s }
34
+ when :react
35
+ value.to_h
36
+ else
37
+ value
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class SchemaEnvelope < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :schema_show
9
+ summary "Return the schema (field shape) for an entry's family, by key."
10
+ surfaces :cli, :mcp
11
+ cli "schema show"
12
+ arg :key, String, required: true, positional: true,
13
+ description: "any key in the family whose schema you want; returns required/optional fields and their types"
14
+
15
+ def initialize(key:)
16
+ super()
17
+ @key = key
18
+ end
19
+
20
+ def call(container:, **)
21
+ manifest = container.manifest
22
+ schemas = container.schemas
23
+ mentry = manifest.resolver.resolve(@key).entry
24
+ schema = schemas.fetch_or_nil(mentry.schema)
25
+ { "protocol" => PROTOCOL, "key" => @key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Uid < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :uid
9
+ summary "Return the stable UID of an entry without reading its body."
10
+ surfaces :cli
11
+ cli "key uid"
12
+ arg :key, String, required: true, positional: true, description: "entry key"
13
+ view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
14
+
15
+ def initialize(key:)
16
+ super()
17
+ @key = key
18
+ end
19
+
20
+ def call(container:, call:)
21
+ Textus::Action::Get.new(key: @key).call(container: container, call: call).uid
22
+ end
23
+
24
+ def self.new(*args, **kwargs)
25
+ return super(**kwargs) unless args.any?
26
+
27
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
28
+ mapped = positional.zip(args).to_h
29
+ super(**mapped.merge(kwargs))
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Where < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :where
9
+ summary "Resolve a key to its zone, owner, and path without reading the body."
10
+ surfaces :cli, :mcp
11
+ arg :key, String, required: true, positional: true,
12
+ description: "dotted key to locate (returns zone, owner, path; does not read content)"
13
+
14
+ def initialize(key:)
15
+ super()
16
+ @key = key
17
+ end
18
+
19
+ def call(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
20
+ manifest = container.manifest
21
+ res = manifest.resolver.resolve(@key)
22
+ mentry = res.entry
23
+ path = res.path
24
+ { "protocol" => PROTOCOL, "key" => @key, "lane" => mentry.lane, "owner" => mentry.owner, "path" => path }
25
+ end
26
+
27
+ def self.new(*args, **kwargs)
28
+ return super(**kwargs) unless args.any?
29
+
30
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
31
+ mapped = positional.zip(args).to_h
32
+ super(**mapped.merge(kwargs))
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class WriteVerb < Base
6
+ private
7
+
8
+ def auth(container)
9
+ Textus::Gate::Auth.new(container)
10
+ end
11
+
12
+ def writer(container, call)
13
+ Textus::Envelope::Writer.from(container: container, call: call)
14
+ end
15
+
16
+ def reader(container)
17
+ Textus::Envelope::Reader.from(container: container)
18
+ end
19
+
20
+ def run_with_cascade(target_key, container:, call:)
21
+ result = yield
22
+ cascade_to_rdeps(target_key, container, call) if target_key
23
+ result
24
+ end
25
+
26
+ def cascade_to_rdeps(key, container, call)
27
+ rdeps = Textus::Action::Rdeps.new(key: key).call(container: container, call: call).fetch("rdeps", [])
28
+ producible = rdeps.select { |dep_key| producible?(dep_key, container) }
29
+ return if producible.empty?
30
+
31
+ producible.each do |dep_key|
32
+ Textus::Jobs::Materialize.new(key: dep_key).call(container:, call:)
33
+ end
34
+ end
35
+
36
+ def producible?(key, container)
37
+ entry = container.manifest.resolver.resolve(key).entry
38
+ !entry.publish_tree.nil?
39
+ rescue Textus::Error
40
+ false
41
+ end
42
+ end
43
+ end
44
+ end
data/lib/textus/boot.rb CHANGED
@@ -1,60 +1,21 @@
1
1
  module Textus
2
2
  # Read-only "what's in this store and how do I use it" envelope.
3
- # A single call gives an agent the working model of a textus-managed
4
- # project: zones and their write authority, entries and their flags,
5
- # registered hooks, write flows, and the CLI verb catalog.
6
- #
7
- # Boot is side-effect-free.
3
+ # Boot is side-effect-free. Reads from pre-computed artifacts and
4
+ # the store catalog rather than computing inline.
8
5
  module Boot
9
6
  PROTOCOL_ID = PROTOCOL
10
7
 
11
- # Per-capability write-flow templates. Each lambda receives the user-facing
12
- # role name and the manifest, and returns guidance for that verb with the
13
- # live zone named by kind (ADR 0034). A role holding multiple verbs gets one
14
- # joined string; roles whose verbs have no template are omitted.
15
- WRITE_FLOW_TEMPLATES = {
16
- author: lambda do |name, manifest|
17
- "edit files in #{zone_label(manifest, :canon, "your canon zone")}, " \
18
- "then 'textus put KEY --as=#{name}'"
19
- end,
20
- keep: lambda do |name, manifest|
21
- "keep durable notes in #{zone_label(manifest, :workspace, "your workspace")}: " \
22
- "'textus put KEY --as=#{name}' (no accept needed)"
23
- end,
24
- propose: lambda do |name, manifest|
25
- authority = manifest.policy.roles_with_capability("author").first || "the author-holder"
26
- "propose changes by writing #{manifest.policy.queue_zone}.* entries with --as=#{name} " \
27
- "and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
28
- end,
29
- converge: lambda do |_name, manifest|
30
- machine = zone_label(manifest, :machine, "machine")
31
- "'textus drain' materializes derived #{machine} entries from their sources and " \
32
- "refreshes stale intake #{machine} entries from their declared source; " \
33
- "derived files are never hand-edited (reactive on canon writes, or a full pass on demand)"
34
- end,
35
- }.freeze
36
-
37
- def self.write_flows_for(manifest)
38
- manifest.data.role_caps.each_with_object({}) do |(name, caps), acc|
39
- flows = caps.filter_map do |verb|
40
- tmpl = WRITE_FLOW_TEMPLATES[verb.to_sym]
41
- tmpl&.call(name, manifest)
42
- end
43
- acc[name] = flows.join(" / ") unless flows.empty?
44
- end
45
- end
46
-
47
- # Human-readable name(s) for the live zone(s) of a given kind, or `fallback`
8
+ # Human-readable name(s) for the live lane(s) of a given kind, or `fallback`
48
9
  # when the manifest declares none. Lets write-flow guidance name the live
49
- # zone by kind instead of a hardcoded instance name (ADR 0034).
50
- def self.zone_label(manifest, kind, fallback)
51
- zones = manifest.policy.zones_of_kind(kind)
52
- zones.empty? ? fallback : zones.join(", ")
10
+ # lane by kind instead of a hardcoded instance name (ADR 0034).
11
+ def self.lane_label(manifest, kind, fallback)
12
+ lanes = manifest.policy.lanes_of_kind(kind)
13
+ lanes.empty? ? fallback : lanes.join(", ")
53
14
  end
54
15
 
55
16
  # Static, store-independent parts of the agent-facing protocol. The
56
17
  # `recipes` and `role_resolution` blocks are derived per-manifest in
57
- # agent_protocol(...) because zone and role names are user-configurable.
18
+ # agent_protocol(...) because lane and role names are user-configurable.
58
19
  AGENT_PROTOCOL_TEMPLATE = {
59
20
  "envelope_shape" => {
60
21
  "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
@@ -79,6 +40,7 @@ module Textus
79
40
  { "name" => "boot" },
80
41
  { "name" => "list" },
81
42
  { "name" => "get" },
43
+ { "name" => "ingest" },
82
44
  { "name" => "where" },
83
45
  { "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
84
46
  { "name" => "put" },
@@ -91,18 +53,16 @@ module Textus
91
53
  { "name" => "blame" },
92
54
  { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
93
55
  { "name" => "doctor" },
94
- { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
95
56
  { "name" => "jobs" },
96
57
  { "name" => "pulse" },
97
- { "name" => "capabilities" },
98
58
  ].freeze
99
59
 
100
60
  # verb token => contract.summary, for every Dispatcher verb that carries a
101
61
  # contract. The single source for a verb's one-line summary (ADR 0039).
102
62
  def self.contract_summaries
103
- Dispatcher::VERBS.values
104
- .select { |k| k.respond_to?(:contract?) && k.contract? }
105
- .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
63
+ Textus::Action::VERBS.values
64
+ .select { |k| k.respond_to?(:contract?) && k.contract? }
65
+ .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
106
66
  end
107
67
 
108
68
  # Build the CLI verb catalog: each summary is derived from its contract when
@@ -119,41 +79,33 @@ module Textus
119
79
  def self.agent_quickstart(manifest, audit_log)
120
80
  agent_role = manifest.policy.proposer_role
121
81
 
122
- writable_zones = manifest.data.declared_zone_kinds.keys.each_with_object([]) do |zname, acc|
123
- acc << zname if agent_role && manifest.policy.zone_writers(zname).include?(agent_role)
82
+ writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |lane_name, acc|
83
+ next unless agent_role
84
+
85
+ verb = manifest.policy.verb_for_lane(lane_name)
86
+ writers = manifest.policy.roles_with_capability(verb)
87
+ acc << lane_name if writers.include?(agent_role)
124
88
  end
125
89
 
126
- propose_zone = manifest.policy.propose_zone_for(agent_role)
90
+ propose_lane = manifest.policy.propose_lane_for(agent_role)
127
91
 
128
92
  {
129
- # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
130
- # agent's real read and write surface, named as verbs the agent calls —
131
- # not CLI strings. read_verbs can neither advertise a verb the agent
132
- # cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
133
- # internal scan, ADR 0085) nor omit one it can
134
- # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
135
- # framing (role is connection-resolved over MCP; there is no stdin).
136
- # writable_zones / propose_zone below carry the agent's write authority.
137
- "read_verbs" => Textus::MCP::Catalog.read_verbs,
138
- "write_verbs" => agent_role ? Textus::MCP::Catalog.write_verbs : [],
139
- "writable_zones" => writable_zones,
140
- "propose_zone" => propose_zone,
93
+ "read_verbs" => Textus::Surfaces::MCP::Catalog.read_verbs,
94
+ "write_verbs" => agent_role ? Textus::Surfaces::MCP::Catalog.write_verbs : [],
95
+ "writable_lanes" => writable_lanes,
96
+ "propose_lane" => propose_lane,
141
97
  "latest_seq" => audit_log.latest_seq,
142
98
  }
143
99
  end
144
100
 
145
- # Recipes reference verbs, not a transport's CLI strings (ADR 0056): every
146
- # step names a verb the agent can call (each transport frames it — CLI as
147
- # `textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This
148
- # keeps shell lines out of the surface an MCP agent reads.
149
101
  def self.recipes(manifest)
150
- queue = manifest.policy.queue_zone
151
- feeds = zone_label(manifest, :machine, "the machine zone")
102
+ queue = manifest.policy.queue_lane
103
+ feeds = lane_label(manifest, :machine, "the machine lane")
152
104
  {
153
105
  "read" => {
154
106
  "purpose" => "find and read an entry",
155
107
  "steps" => [
156
- "list (zone:, prefix:) — discover keys without reading bodies",
108
+ "list (lane:, prefix:) — discover keys without reading bodies",
157
109
  "get KEY — returns the entry envelope",
158
110
  ],
159
111
  },
@@ -168,17 +120,17 @@ module Textus
168
120
  "propose" => {
169
121
  "purpose" => "agent suggests a change for human review",
170
122
  "agent_steps" => [
171
- "propose KEY — writes the change into the #{queue} zone for review",
123
+ "propose KEY — writes the change into the #{queue} lane for review",
172
124
  ],
173
125
  "human_steps" => [
174
- "accept #{queue}.KEY — promotes the proposal into its target zone",
126
+ "accept #{queue}.KEY — promotes the proposal into its target lane",
175
127
  ],
176
128
  },
177
129
  "drain" => {
178
130
  "purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
179
131
  "steps" => [
180
132
  "pulse — its `stale` list names entries past their ttl",
181
- "drain (zone: #{feeds}) — re-pull the stale entries",
133
+ "drain (lane: #{feeds}) — re-pull the stale entries",
182
134
  ],
183
135
  },
184
136
  }
@@ -196,76 +148,56 @@ module Textus
196
148
  )
197
149
  end
198
150
 
199
- def self.build(container:, lean: false)
151
+ def self.build(container:)
200
152
  manifest = container.manifest
201
153
  etag = Textus::Etag.for_contract(container.root)
202
154
 
203
- if lean
204
- return {
205
- "protocol" => PROTOCOL_ID,
206
- "store_root" => container.root,
207
- "zones" => zones_for(manifest),
208
- "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
209
- "contract_etag" => etag,
210
- }
211
- end
212
-
213
155
  {
214
156
  "protocol" => PROTOCOL_ID,
215
157
  "store_root" => container.root,
216
- "zones" => zones_for(manifest),
217
- "entries" => entries_for(manifest),
218
- "hooks" => hooks_for_container(container),
219
- "write_flows" => write_flows_for(manifest),
220
- "cli_verbs" => CLI_VERBS.map(&:dup),
221
- "agent_protocol" => agent_protocol(manifest),
222
- "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
223
158
  "contract_etag" => etag,
224
- "docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
225
- }
159
+ "lanes" => lanes_for(manifest),
160
+ "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
161
+ "orientation" => read_artifact_content(container, "artifacts.orientation"),
162
+ "context" => read_boot_context(container),
163
+ "index_key" => "artifacts.index",
164
+ "agent_protocol" => agent_protocol(manifest),
165
+ }.compact
226
166
  end
227
167
 
228
- def self.zones_for(manifest)
229
- manifest.data.declared_zone_kinds.keys.map do |name|
230
- row = { "name" => name, "writers" => manifest.policy.zone_writers(name) }
231
- kind = manifest.policy.declared_kind(name)
232
- row["kind"] = kind.to_s if kind
233
- purpose = manifest.data.zone_descs[name]
234
- row["purpose"] = purpose if purpose && !purpose.empty?
235
- row
236
- end
237
- end
168
+ def self.read_artifact_content(container, key)
169
+ res = container.manifest.resolver.resolve(key)
170
+ return nil unless res.path && File.exist?(res.path)
238
171
 
239
- def self.entries_for(manifest)
240
- manifest.data.entries.map do |e|
241
- derived = e.derived?
242
- {
243
- "key" => e.key,
244
- "zone" => e.zone,
245
- "schema" => e.schema,
246
- "nested" => e.is_a?(Textus::Manifest::Entry::Nested),
247
- "owner" => e.owner,
248
- "format" => e.format,
249
- "derived" => derived,
250
- "intake" => e.intake?,
251
- "publish_to" => Array(e.publish_to),
252
- }
253
- end
172
+ call = Textus::Call.build(role: Textus::Role::DEFAULT)
173
+ env = Textus::Action::Get.new(key: key).call(container: container, call: call)
174
+ env&.content
175
+ rescue Textus::Error
176
+ nil
254
177
  end
255
178
 
256
- def self.hooks_for_container(container)
257
- hooks_for_container_internal(rpc: container.rpc, events: container.events)
179
+ def self.read_boot_context(container)
180
+ res = container.manifest.resolver.resolve("knowledge.boot")
181
+ return nil unless res.path && File.exist?(res.path)
182
+
183
+ call = Textus::Call.build(role: Textus::Role::DEFAULT)
184
+ env = Textus::Action::Get.new(key: "knowledge.boot").call(container: container, call: call)
185
+ body = env&.body&.strip
186
+ body.nil? || body.empty? ? nil : body
187
+ rescue Textus::Error
188
+ nil
258
189
  end
259
190
 
260
- def self.hooks_for_container_internal(rpc:, events:)
261
- sections = {}
262
- Hooks::Catalog::RPC.each_key do |event|
263
- sections[event.to_s] = rpc.names(event).map(&:to_s).sort
264
- end
265
- Hooks::Catalog::PUBSUB.each_key do |event|
266
- sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
191
+ def self.lanes_for(manifest)
192
+ manifest.data.declared_lane_kinds.keys.map do |name|
193
+ verb = manifest.policy.verb_for_lane(name)
194
+ row = { "name" => name, "writers" => manifest.policy.roles_with_capability(verb) }
195
+ kind = manifest.policy.declared_kind(name)
196
+ row["kind"] = kind.to_s if kind
197
+ purpose = manifest.data.lane_descs[name]
198
+ row["purpose"] = purpose if purpose && !purpose.empty?
199
+ row
267
200
  end
268
- sections
269
201
  end
270
202
  end
271
203
  end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ module Command
3
+ Get = Data.define(:key, :role)
4
+ Put = Data.define(:key, :meta, :body, :content, :if_etag, :role)
5
+ Propose = Data.define(:key, :meta, :body, :content, :role, :pending_key) do
6
+ def initialize(key:, role:, meta: nil, body: nil, content: nil, pending_key: nil)
7
+ super
8
+ end
9
+ end
10
+ KeyDelete = Data.define(:key, :if_etag, :role)
11
+ KeyMv = Data.define(:old_key, :new_key, :dry_run, :role)
12
+ Accept = Data.define(:pending_key, :role)
13
+ Reject = Data.define(:pending_key, :role)
14
+ Enqueue = Data.define(:type, :args, :role)
15
+ Ingest = Data.define(:kind, :slug, :url, :path, :zone, :label, :role) do
16
+ def initialize(kind:, slug:, role:, url: nil, path: nil, zone: nil, label: nil)
17
+ super
18
+ end
19
+ end
20
+ List = Data.define(:prefix, :lane, :role)
21
+ Where = Data.define(:key, :role)
22
+ Uid = Data.define(:key, :role)
23
+ Blame = Data.define(:key, :limit, :role)
24
+ Audit = Data.define(:key, :lane, :verb, :since, :seq_since, :correlation_id, :limit, :role)
25
+ Deps = Data.define(:key, :role)
26
+ Rdeps = Data.define(:key, :role)
27
+ Pulse = Data.define(:since, :role)
28
+ RuleExplain = Data.define(:key, :detail, :role)
29
+ RuleList = Data.define(:role)
30
+ RuleLint = Data.define(:candidate_yaml, :role)
31
+ Published = Data.define(:role)
32
+ SchemaShow = Data.define(:key, :role)
33
+ Doctor = Data.define(:checks, :role)
34
+ Boot = Data.define(:role)
35
+ Jobs = Data.define(:state, :action, :job_id, :role)
36
+ DataMv = Data.define(:from, :to, :dry_run, :role)
37
+ KeyMvPrefix = Data.define(:from_prefix, :to_prefix, :dry_run, :role)
38
+ KeyDeletePrefix = Data.define(:prefix, :dry_run, :role)
39
+ Drain = Data.define(:prefix, :lane, :role)
40
+ end
41
+ end
@@ -5,6 +5,6 @@ module Textus
5
5
  # so this `Data.define` is the single source of truth for the field set.
6
6
  Container = Data.define(
7
7
  :manifest, :file_store, :schemas, :root,
8
- :audit_log, :events, :rpc
8
+ :audit_log, :workflows, :gate
9
9
  )
10
10
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Domain
2
+ module Core
3
3
  # Parses a duration value into whole seconds. Accepts a bare integer (or
4
4
  # integer-string) of seconds, or `<n><unit>` with unit s/m/h/d. Returns
5
5
  # nil for nil or any unparseable value.