textus 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
@@ -0,0 +1,67 @@
1
+ module Textus
2
+ module Background
3
+ class Worker
4
+ Summary = Struct.new(:completed, :failed, keyword_init: true)
5
+
6
+ def self.for(container:, queue:)
7
+ new(queue: queue, container: container,
8
+ lease_ttl: container.manifest.data.worker_config[:lease_ttl])
9
+ end
10
+
11
+ def initialize(queue:, container:, lease_ttl: 60)
12
+ @queue = queue
13
+ @container = container
14
+ @lease_ttl = lease_ttl
15
+ end
16
+
17
+ def drain(worker_id: "drain-#{Process.pid}")
18
+ completed = 0
19
+ failed = 0
20
+ loop do
21
+ leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
22
+ break unless leased
23
+
24
+ case run_one(leased)
25
+ when :completed then completed += 1
26
+ when :dead_lettered then failed += 1
27
+ end
28
+ end
29
+ Summary.new(completed: completed, failed: failed)
30
+ end
31
+
32
+ def drain_pool(pool: 4)
33
+ summaries = []
34
+ mutex = Mutex.new
35
+ threads = Array.new(pool) do |i|
36
+ Thread.new do
37
+ s = drain(worker_id: "pool-#{Process.pid}-#{i}")
38
+ mutex.synchronize { summaries << s }
39
+ end
40
+ end
41
+ threads.each(&:join)
42
+ Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
43
+ end
44
+
45
+ private
46
+
47
+ def run_one(leased)
48
+ job = leased.job
49
+ klass = Textus::Background::Job.fetch(job.type)
50
+ action = if klass.instance_method(:initialize).parameters.any?
51
+ klass.new(**job.args.transform_keys(&:to_sym))
52
+ else
53
+ klass.new
54
+ end
55
+ call = Textus::Call.build(
56
+ role: job.enqueued_by || Textus::Role::AUTOMATION,
57
+ correlation_id: SecureRandom.uuid,
58
+ )
59
+ action.call(container: @container, call: call)
60
+ @queue.ack(leased)
61
+ :completed
62
+ rescue StandardError => e
63
+ @queue.fail(leased, error: e.message)
64
+ end
65
+ end
66
+ end
67
+ end
data/lib/textus/boot.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  # Read-only "what's in this store and how do I use it" envelope.
3
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,
4
+ # project: lanes and their write authority, entries and their flags,
5
5
  # registered hooks, write flows, and the CLI verb catalog.
6
6
  #
7
7
  # Boot is side-effect-free.
@@ -10,25 +10,25 @@ module Textus
10
10
 
11
11
  # Per-capability write-flow templates. Each lambda receives the user-facing
12
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
13
+ # live lane named by kind (ADR 0034). A role holding multiple verbs gets one
14
14
  # joined string; roles whose verbs have no template are omitted.
15
15
  WRITE_FLOW_TEMPLATES = {
16
16
  author: lambda do |name, manifest|
17
- "edit files in #{zone_label(manifest, :canon, "your canon zone")}, " \
17
+ "edit files in #{lane_label(manifest, :canon, "your canon lane")}, " \
18
18
  "then 'textus put KEY --as=#{name}'"
19
19
  end,
20
20
  keep: lambda do |name, manifest|
21
- "keep durable notes in #{zone_label(manifest, :workspace, "your workspace")}: " \
21
+ "keep durable notes in #{lane_label(manifest, :workspace, "your workspace")}: " \
22
22
  "'textus put KEY --as=#{name}' (no accept needed)"
23
23
  end,
24
24
  propose: lambda do |name, manifest|
25
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} " \
26
+ "propose changes by writing #{manifest.policy.queue_lane}.* entries with --as=#{name} " \
27
27
  "and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
28
28
  end,
29
- reconcile: lambda do |_name, manifest|
30
- machine = zone_label(manifest, :machine, "machine")
31
- "'textus reconcile' materializes derived #{machine} entries from their sources and " \
29
+ converge: lambda do |_name, manifest|
30
+ machine = lane_label(manifest, :machine, "machine")
31
+ "'textus drain' materializes derived #{machine} entries from their sources and " \
32
32
  "refreshes stale intake #{machine} entries from their declared source; " \
33
33
  "derived files are never hand-edited (reactive on canon writes, or a full pass on demand)"
34
34
  end,
@@ -44,17 +44,17 @@ module Textus
44
44
  end
45
45
  end
46
46
 
47
- # Human-readable name(s) for the live zone(s) of a given kind, or `fallback`
47
+ # Human-readable name(s) for the live lane(s) of a given kind, or `fallback`
48
48
  # 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(", ")
49
+ # lane by kind instead of a hardcoded instance name (ADR 0034).
50
+ def self.lane_label(manifest, kind, fallback)
51
+ lanes = manifest.policy.lanes_of_kind(kind)
52
+ lanes.empty? ? fallback : lanes.join(", ")
53
53
  end
54
54
 
55
55
  # Static, store-independent parts of the agent-facing protocol. The
56
56
  # `recipes` and `role_resolution` blocks are derived per-manifest in
57
- # agent_protocol(...) because zone and role names are user-configurable.
57
+ # agent_protocol(...) because lane and role names are user-configurable.
58
58
  AGENT_PROTOCOL_TEMPLATE = {
59
59
  "envelope_shape" => {
60
60
  "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
@@ -84,23 +84,23 @@ module Textus
84
84
  { "name" => "put" },
85
85
  { "name" => "propose" },
86
86
  { "name" => "accept" },
87
+ { "name" => "enqueue" },
87
88
  { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
88
- { "name" => "reconcile" },
89
+ { "name" => "drain" },
89
90
  { "name" => "audit" },
90
91
  { "name" => "blame" },
91
92
  { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
92
93
  { "name" => "doctor" },
93
- { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
94
+ { "name" => "jobs" },
94
95
  { "name" => "pulse" },
95
- { "name" => "capabilities" },
96
96
  ].freeze
97
97
 
98
98
  # verb token => contract.summary, for every Dispatcher verb that carries a
99
99
  # contract. The single source for a verb's one-line summary (ADR 0039).
100
100
  def self.contract_summaries
101
- Dispatcher::VERBS.values
102
- .select { |k| k.respond_to?(:contract?) && k.contract? }
103
- .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
101
+ Textus::Action::VERBS.values
102
+ .select { |k| k.respond_to?(:contract?) && k.contract? }
103
+ .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
104
104
  end
105
105
 
106
106
  # Build the CLI verb catalog: each summary is derived from its contract when
@@ -117,11 +117,15 @@ module Textus
117
117
  def self.agent_quickstart(manifest, audit_log)
118
118
  agent_role = manifest.policy.proposer_role
119
119
 
120
- writable_zones = manifest.data.declared_zone_kinds.keys.each_with_object([]) do |zname, acc|
121
- acc << zname if agent_role && manifest.policy.zone_writers(zname).include?(agent_role)
120
+ writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |lane_name, acc|
121
+ next unless agent_role
122
+
123
+ verb = manifest.policy.verb_for_lane(lane_name)
124
+ writers = manifest.policy.roles_with_capability(verb)
125
+ acc << lane_name if writers.include?(agent_role)
122
126
  end
123
127
 
124
- propose_zone = manifest.policy.propose_zone_for(agent_role)
128
+ propose_lane = manifest.policy.propose_lane_for(agent_role)
125
129
 
126
130
  {
127
131
  # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
@@ -131,11 +135,11 @@ module Textus
131
135
  # internal scan, ADR 0085) nor omit one it can
132
136
  # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
133
137
  # framing (role is connection-resolved over MCP; there is no stdin).
134
- # writable_zones / propose_zone below carry the agent's write authority.
135
- "read_verbs" => Textus::MCP::Catalog.read_verbs,
136
- "write_verbs" => agent_role ? Textus::MCP::Catalog.write_verbs : [],
137
- "writable_zones" => writable_zones,
138
- "propose_zone" => propose_zone,
138
+ # writable_lanes / propose_lane below carry the agent's write authority.
139
+ "read_verbs" => Textus::Surfaces::MCP::Catalog.read_verbs,
140
+ "write_verbs" => agent_role ? Textus::Surfaces::MCP::Catalog.write_verbs : [],
141
+ "writable_lanes" => writable_lanes,
142
+ "propose_lane" => propose_lane,
139
143
  "latest_seq" => audit_log.latest_seq,
140
144
  }
141
145
  end
@@ -145,13 +149,13 @@ module Textus
145
149
  # `textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This
146
150
  # keeps shell lines out of the surface an MCP agent reads.
147
151
  def self.recipes(manifest)
148
- queue = manifest.policy.queue_zone
149
- feeds = zone_label(manifest, :machine, "the machine zone")
152
+ queue = manifest.policy.queue_lane
153
+ feeds = lane_label(manifest, :machine, "the machine lane")
150
154
  {
151
155
  "read" => {
152
156
  "purpose" => "find and read an entry",
153
157
  "steps" => [
154
- "list (zone:, prefix:) — discover keys without reading bodies",
158
+ "list (lane:, prefix:) — discover keys without reading bodies",
155
159
  "get KEY — returns the entry envelope",
156
160
  ],
157
161
  },
@@ -166,17 +170,17 @@ module Textus
166
170
  "propose" => {
167
171
  "purpose" => "agent suggests a change for human review",
168
172
  "agent_steps" => [
169
- "propose KEY — writes the change into the #{queue} zone for review",
173
+ "propose KEY — writes the change into the #{queue} lane for review",
170
174
  ],
171
175
  "human_steps" => [
172
- "accept #{queue}.KEY — promotes the proposal into its target zone",
176
+ "accept #{queue}.KEY — promotes the proposal into its target lane",
173
177
  ],
174
178
  },
175
- "reconcile" => {
179
+ "drain" => {
176
180
  "purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
177
181
  "steps" => [
178
182
  "pulse — its `stale` list names entries past their ttl",
179
- "reconcile (zone: #{feeds}) — re-pull the stale entries",
183
+ "drain (lane: #{feeds}) — re-pull the stale entries",
180
184
  ],
181
185
  },
182
186
  }
@@ -202,7 +206,7 @@ module Textus
202
206
  return {
203
207
  "protocol" => PROTOCOL_ID,
204
208
  "store_root" => container.root,
205
- "zones" => zones_for(manifest),
209
+ "lanes" => lanes_for(manifest),
206
210
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
207
211
  "contract_etag" => etag,
208
212
  }
@@ -211,7 +215,7 @@ module Textus
211
215
  {
212
216
  "protocol" => PROTOCOL_ID,
213
217
  "store_root" => container.root,
214
- "zones" => zones_for(manifest),
218
+ "lanes" => lanes_for(manifest),
215
219
  "entries" => entries_for(manifest),
216
220
  "hooks" => hooks_for_container(container),
217
221
  "write_flows" => write_flows_for(manifest),
@@ -219,16 +223,17 @@ module Textus
219
223
  "agent_protocol" => agent_protocol(manifest),
220
224
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
221
225
  "contract_etag" => etag,
222
- "docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
226
+ "docs" => { "spec" => "SPEC.md", "example" => ".textus/" },
223
227
  }
224
228
  end
225
229
 
226
- def self.zones_for(manifest)
227
- manifest.data.declared_zone_kinds.keys.map do |name|
228
- row = { "name" => name, "writers" => manifest.policy.zone_writers(name) }
230
+ def self.lanes_for(manifest)
231
+ manifest.data.declared_lane_kinds.keys.map do |name|
232
+ verb = manifest.policy.verb_for_lane(name)
233
+ row = { "name" => name, "writers" => manifest.policy.roles_with_capability(verb) }
229
234
  kind = manifest.policy.declared_kind(name)
230
235
  row["kind"] = kind.to_s if kind
231
- purpose = manifest.data.zone_descs[name]
236
+ purpose = manifest.data.lane_descs[name]
232
237
  row["purpose"] = purpose if purpose && !purpose.empty?
233
238
  row
234
239
  end
@@ -239,7 +244,7 @@ module Textus
239
244
  derived = e.derived?
240
245
  {
241
246
  "key" => e.key,
242
- "zone" => e.zone,
247
+ "lane" => e.lane,
243
248
  "schema" => e.schema,
244
249
  "nested" => e.is_a?(Textus::Manifest::Entry::Nested),
245
250
  "owner" => e.owner,
@@ -252,16 +257,21 @@ module Textus
252
257
  end
253
258
 
254
259
  def self.hooks_for_container(container)
255
- hooks_for_container_internal(rpc: container.rpc, events: container.events)
260
+ hooks_for_container_internal(steps: container.steps)
256
261
  end
257
262
 
258
- def self.hooks_for_container_internal(rpc:, events:)
263
+ def self.hooks_for_container_internal(steps:)
259
264
  sections = {}
260
- Hooks::Catalog::RPC.each_key do |event|
261
- sections[event.to_s] = rpc.names(event).map(&:to_s).sort
265
+ rpc_kind_map = {
266
+ resolve_handler: :fetch,
267
+ transform_rows: :transform,
268
+ validate: :validate,
269
+ }
270
+ Step::Catalog::RPC.each_key do |event|
271
+ sections[event.to_s] = steps.names(rpc_kind_map.fetch(event)).map(&:to_s).sort
262
272
  end
263
- Hooks::Catalog::PUBSUB.each_key do |event|
264
- sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
273
+ Step::Catalog::PUBSUB.each_key do |event|
274
+ sections[event.to_s] = steps.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
265
275
  end
266
276
  sections
267
277
  end
@@ -0,0 +1,36 @@
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
+ List = Data.define(:prefix, :lane, :role)
16
+ Where = Data.define(:key, :role)
17
+ Uid = Data.define(:key, :role)
18
+ Blame = Data.define(:key, :limit, :role)
19
+ Audit = Data.define(:key, :lane, :verb, :since, :seq_since, :correlation_id, :limit, :role)
20
+ Deps = Data.define(:key, :role)
21
+ Rdeps = Data.define(:key, :role)
22
+ Pulse = Data.define(:since, :role)
23
+ RuleExplain = Data.define(:key, :detail, :role)
24
+ RuleList = Data.define(:role)
25
+ RuleLint = Data.define(:candidate_yaml, :role)
26
+ Published = Data.define(:role)
27
+ SchemaShow = Data.define(:key, :role)
28
+ Doctor = Data.define(:checks, :role)
29
+ Boot = Data.define(:lean, :role)
30
+ Jobs = Data.define(:state, :action, :job_id, :role)
31
+ DataMv = Data.define(:from, :to, :dry_run, :role)
32
+ KeyMvPrefix = Data.define(:from_prefix, :to_prefix, :dry_run, :role)
33
+ KeyDeletePrefix = Data.define(:prefix, :dry_run, :role)
34
+ Drain = Data.define(:prefix, :lane, :role)
35
+ end
36
+ 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, :steps, :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.
@@ -1,17 +1,17 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  module Freshness
6
6
  # The single currency evaluator (ADR 0099). Answers "is the stored data
7
7
  # stale relative to its source?" for every produce-method:
8
- # - intake (source.from: handler) -> AGE signal: now - basis > source.ttl,
8
+ # - intake (source.from: fetch) -> AGE signal: now - basis > source.ttl,
9
9
  # basis = _meta.last_fetched_at (else file mtime). No ttl -> :no_policy
10
10
  # (skipped — a cadence-less handler is not auto-re-pulled).
11
- # - derived/external -> DRIFT signal: a source changed since generated.at
11
+ # - external -> DRIFT signal: a source changed since generated.at
12
12
  # (surfaced by the doctor generator_drift check; derived entries annotate
13
- # fresh at read time because reconcile converges them reactively).
14
- # Replaces Domain::IntakeStaleness and Domain::Staleness::GeneratorCheck and
13
+ # fresh at read time because converge runs them reactively).
14
+ # Replaces Core::IntakeStaleness and Core::Staleness::GeneratorCheck and
15
15
  # the inline copies in Read::Get / Read::Freshness.
16
16
  class Evaluator
17
17
  def initialize(manifest:, file_stat:, clock:)
@@ -32,11 +32,11 @@ module Textus
32
32
  Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
33
33
  end
34
34
 
35
- # Keys of intake entries past their source.ttl — the reconcile produce
36
- # scope (replaces Domain::IntakeStaleness#call). A ttl-less intake entry
35
+ # Keys of intake entries past their source.ttl — the converge produce
36
+ # scope (replaces Core::IntakeStaleness#call). A ttl-less intake entry
37
37
  # is :no_policy and skipped; a never-recorded one (with a ttl) is stale.
38
- def stale_intake_keys(prefix: nil, zone: nil)
39
- @manifest.data.entries.select { |m| due?(m, prefix: prefix, zone: zone) }.map(&:key)
38
+ def stale_intake_keys(prefix: nil, lane: nil)
39
+ @manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
40
40
  end
41
41
 
42
42
  # Age basis as a Time (or nil): _meta.last_fetched_at when present, else
@@ -62,9 +62,9 @@ module Textus
62
62
 
63
63
  def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
64
64
 
65
- def due?(mentry, prefix:, zone:)
65
+ def due?(mentry, prefix:, lane:)
66
66
  return false unless mentry.intake?
67
- return false if zone && mentry.zone != zone
67
+ return false if lane && mentry.lane != lane
68
68
  return false if prefix && !mentry.key.start_with?(prefix)
69
69
 
70
70
  ttl = mentry.source.ttl_seconds
@@ -92,7 +92,7 @@ module Textus
92
92
 
93
93
  # --- generator drift (lifted from Staleness::GeneratorCheck) ---
94
94
 
95
- def drift_applicable?(mentry) = mentry.derived? && mentry.external?
95
+ def drift_applicable?(mentry) = mentry.external?
96
96
 
97
97
  def drift_reason(mentry, path)
98
98
  return "derived entry has never been generated" unless @file_stat.exists?(path)
@@ -158,7 +158,7 @@ module Textus
158
158
  def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
159
159
 
160
160
  def drift_row(mentry, path, reason)
161
- { "key" => mentry.key, "path" => path, "generator" => mentry.raw["compute"], "reason" => reason }
161
+ { "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
162
162
  end
163
163
  end
164
164
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  module Freshness
6
6
  # Value object describing the freshness annotation attached to an Envelope
7
- # after a currency evaluation (ADR 0099 — was Domain::Freshness).
7
+ # after a currency evaluation (ADR 0099 — was Core::Freshness).
8
8
  #
9
9
  # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
10
  # full field set. It emits the legacy keys ("stale", "stale_reason",
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  # Currency — "is the stored data stale relative to its source?" (ADR 0099).
6
6
  # The home of the single Freshness evaluator and its Verdict value object.
7
- # Distinct from Domain::Retention (GC dueness, Q2).
7
+ # Distinct from Core::Retention (GC dueness, Q2).
8
8
  module Freshness
9
9
  end
10
10
  end
@@ -1,12 +1,12 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  module Retention
6
6
  # Retention sweep reporter (ADR 0093/0099). Which entries are past their
7
7
  # `retention:` ttl and the destructive action that applies. Age basis: file
8
- # mtime. Only drop/archive. Renamed off the Domain::Retention vs
9
- # Domain::Policy::Retention collision (ADR 0099).
8
+ # mtime. Only drop/archive. Renamed off the Core::Retention vs
9
+ # Manifest::Policy::Retention collision (ADR 0099).
10
10
  class Sweep
11
11
  def self.expired?(ttl_seconds:, mtime:, now:)
12
12
  return false if ttl_seconds.nil? || mtime.nil?
@@ -20,16 +20,16 @@ module Textus
20
20
  @clock = clock
21
21
  end
22
22
 
23
- def call(prefix: nil, zone: nil)
23
+ def call(prefix: nil, lane: nil)
24
24
  @manifest.data.entries
25
- .select { |m| matches?(m, prefix: prefix, zone: zone) }
25
+ .select { |m| matches?(m, prefix: prefix, lane: lane) }
26
26
  .flat_map { |m| rows_for(m) }
27
27
  end
28
28
 
29
29
  private
30
30
 
31
- def matches?(mentry, prefix:, zone:)
32
- return false if zone && mentry.zone != zone
31
+ def matches?(mentry, prefix:, lane:)
32
+ return false if lane && mentry.lane != lane
33
33
  return false if prefix && !Textus::Key::Matching.matches_prefix?(
34
34
  mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
35
35
  )
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  # Retention — "is the entry old enough to retire?" (Q2, ADR 0093/0099).
6
6
  # GC dueness, orthogonal to Freshness (content currency). The reporter is
7
- # Domain::Retention::Sweep; the manifest rule policy is Domain::Policy::Retention.
7
+ # Core::Retention::Sweep; the manifest rule policy is Manifest::Policy::Retention.
8
8
  module Retention
9
9
  end
10
10
  end
@@ -1,7 +1,7 @@
1
1
  require "digest"
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  # Pure value object representing a published-file sentinel. Holds the
6
6
  # recorded target path, source path, sha256 checksum, and publish mode.
7
7
  # Has no filesystem I/O — path layout and persistence live in
@@ -8,7 +8,7 @@ module Textus
8
8
  # verb reported.
9
9
  class GeneratorDrift < Check
10
10
  def call
11
- gen = Textus::Domain::Freshness::Evaluator.new(
11
+ gen = Textus::Core::Freshness::Evaluator.new(
12
12
  manifest: manifest,
13
13
  file_stat: Textus::Ports::Storage::FileStat.new,
14
14
  clock: Textus::Ports::Clock.new,
@@ -19,7 +19,7 @@ module Textus
19
19
  "level" => "warning",
20
20
  "subject" => row["key"],
21
21
  "message" => row["reason"],
22
- "fix" => "rematerialize the entry: `textus reconcile`",
22
+ "fix" => "rematerialize the entry: `textus drain`",
23
23
  }
24
24
  end
25
25
  end
@@ -0,0 +1,34 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # For every entry with a fetch handler, look up its handler_permit policy
5
+ # (if any) and verify the declared handler is permitted. Emits a failure
6
+ # when the handler is rejected by policy.
7
+ class HandlerPermit < 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
+ permit = manifest.rules.for(mentry.key).handler_permit
16
+ next if permit.nil?
17
+ next if permit.permits?(handler)
18
+
19
+ out << {
20
+ "code" => "policy.handler_not_permitted",
21
+ "level" => "error",
22
+ "subject" => mentry.key,
23
+ "message" => "entry '#{mentry.key}' declares source.handler='#{handler}' but " \
24
+ "handler_permit allows only: #{permit.handlers.join(", ")}",
25
+ "fix" => "change handler to one of [#{permit.handlers.join(", ")}] or " \
26
+ "extend handler_permit in .textus/manifest.yaml",
27
+ }
28
+ end
29
+ out
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -4,26 +4,19 @@ module Textus
4
4
  class Hooks < Check
5
5
  def call
6
6
  out = []
7
- dir = File.join(root, "hooks")
7
+ dir = File.join(root, "steps")
8
8
  return out unless File.directory?(dir)
9
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
10
+ Textus::Step::Loader.new(registry: Textus::Step::RegistryStore.new).load_dir(dir)
11
+ out
12
+ rescue Textus::UsageError => e
13
+ out << {
14
+ "code" => "step.load_failed",
15
+ "level" => "error",
16
+ "subject" => "steps",
17
+ "message" => e.message,
18
+ "fix" => "open the named step file under .textus/steps/ and fix the error",
19
+ }
27
20
  out
28
21
  end
29
22
  end
@@ -8,7 +8,7 @@ module Textus
8
8
  next unless entry.nested?
9
9
  next if entry.publish_mode.keyless? # publish_tree files are opaque payload, never keys (ADR 0047)
10
10
 
11
- base = File.join(root, "zones", entry.path)
11
+ base = File.join(root, Textus::Key::Path.normalize_relative_path(entry.path))
12
12
  next unless File.directory?(base)
13
13
 
14
14
  check_all_paths(entry, base, out)