textus 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. data/lib/textus/write/reject.rb +0 -68
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,24 +10,24 @@ 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
29
  converge: lambda do |_name, manifest|
30
- machine = zone_label(manifest, :machine, "machine")
30
+ machine = lane_label(manifest, :machine, "machine")
31
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)"
@@ -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",
@@ -91,18 +91,16 @@ module Textus
91
91
  { "name" => "blame" },
92
92
  { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
93
93
  { "name" => "doctor" },
94
- { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
95
94
  { "name" => "jobs" },
96
95
  { "name" => "pulse" },
97
- { "name" => "capabilities" },
98
96
  ].freeze
99
97
 
100
98
  # verb token => contract.summary, for every Dispatcher verb that carries a
101
99
  # contract. The single source for a verb's one-line summary (ADR 0039).
102
100
  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] }
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] }
106
104
  end
107
105
 
108
106
  # Build the CLI verb catalog: each summary is derived from its contract when
@@ -119,11 +117,15 @@ module Textus
119
117
  def self.agent_quickstart(manifest, audit_log)
120
118
  agent_role = manifest.policy.proposer_role
121
119
 
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)
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)
124
126
  end
125
127
 
126
- propose_zone = manifest.policy.propose_zone_for(agent_role)
128
+ propose_lane = manifest.policy.propose_lane_for(agent_role)
127
129
 
128
130
  {
129
131
  # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
@@ -133,11 +135,11 @@ module Textus
133
135
  # internal scan, ADR 0085) nor omit one it can
134
136
  # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
135
137
  # 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,
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,
141
143
  "latest_seq" => audit_log.latest_seq,
142
144
  }
143
145
  end
@@ -147,13 +149,13 @@ module Textus
147
149
  # `textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This
148
150
  # keeps shell lines out of the surface an MCP agent reads.
149
151
  def self.recipes(manifest)
150
- queue = manifest.policy.queue_zone
151
- feeds = zone_label(manifest, :machine, "the machine zone")
152
+ queue = manifest.policy.queue_lane
153
+ feeds = lane_label(manifest, :machine, "the machine lane")
152
154
  {
153
155
  "read" => {
154
156
  "purpose" => "find and read an entry",
155
157
  "steps" => [
156
- "list (zone:, prefix:) — discover keys without reading bodies",
158
+ "list (lane:, prefix:) — discover keys without reading bodies",
157
159
  "get KEY — returns the entry envelope",
158
160
  ],
159
161
  },
@@ -168,17 +170,17 @@ module Textus
168
170
  "propose" => {
169
171
  "purpose" => "agent suggests a change for human review",
170
172
  "agent_steps" => [
171
- "propose KEY — writes the change into the #{queue} zone for review",
173
+ "propose KEY — writes the change into the #{queue} lane for review",
172
174
  ],
173
175
  "human_steps" => [
174
- "accept #{queue}.KEY — promotes the proposal into its target zone",
176
+ "accept #{queue}.KEY — promotes the proposal into its target lane",
175
177
  ],
176
178
  },
177
179
  "drain" => {
178
180
  "purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
179
181
  "steps" => [
180
182
  "pulse — its `stale` list names entries past their ttl",
181
- "drain (zone: #{feeds}) — re-pull the stale entries",
183
+ "drain (lane: #{feeds}) — re-pull the stale entries",
182
184
  ],
183
185
  },
184
186
  }
@@ -204,7 +206,7 @@ module Textus
204
206
  return {
205
207
  "protocol" => PROTOCOL_ID,
206
208
  "store_root" => container.root,
207
- "zones" => zones_for(manifest),
209
+ "lanes" => lanes_for(manifest),
208
210
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
209
211
  "contract_etag" => etag,
210
212
  }
@@ -213,7 +215,7 @@ module Textus
213
215
  {
214
216
  "protocol" => PROTOCOL_ID,
215
217
  "store_root" => container.root,
216
- "zones" => zones_for(manifest),
218
+ "lanes" => lanes_for(manifest),
217
219
  "entries" => entries_for(manifest),
218
220
  "hooks" => hooks_for_container(container),
219
221
  "write_flows" => write_flows_for(manifest),
@@ -221,16 +223,17 @@ module Textus
221
223
  "agent_protocol" => agent_protocol(manifest),
222
224
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
223
225
  "contract_etag" => etag,
224
- "docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
226
+ "docs" => { "spec" => "SPEC.md", "example" => ".textus/" },
225
227
  }
226
228
  end
227
229
 
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) }
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) }
231
234
  kind = manifest.policy.declared_kind(name)
232
235
  row["kind"] = kind.to_s if kind
233
- purpose = manifest.data.zone_descs[name]
236
+ purpose = manifest.data.lane_descs[name]
234
237
  row["purpose"] = purpose if purpose && !purpose.empty?
235
238
  row
236
239
  end
@@ -241,7 +244,7 @@ module Textus
241
244
  derived = e.derived?
242
245
  {
243
246
  "key" => e.key,
244
- "zone" => e.zone,
247
+ "lane" => e.lane,
245
248
  "schema" => e.schema,
246
249
  "nested" => e.is_a?(Textus::Manifest::Entry::Nested),
247
250
  "owner" => e.owner,
@@ -254,16 +257,21 @@ module Textus
254
257
  end
255
258
 
256
259
  def self.hooks_for_container(container)
257
- hooks_for_container_internal(rpc: container.rpc, events: container.events)
260
+ hooks_for_container_internal(steps: container.steps)
258
261
  end
259
262
 
260
- def self.hooks_for_container_internal(rpc:, events:)
263
+ def self.hooks_for_container_internal(steps:)
261
264
  sections = {}
262
- Hooks::Catalog::RPC.each_key do |event|
263
- 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
264
272
  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
273
+ Step::Catalog::PUBSUB.each_key do |event|
274
+ sections[event.to_s] = steps.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
267
275
  end
268
276
  sections
269
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
13
  # fresh at read time because converge runs them reactively).
14
- # Replaces Domain::IntakeStaleness and Domain::Staleness::GeneratorCheck and
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:)
@@ -33,10 +33,10 @@ module Textus
33
33
  end
34
34
 
35
35
  # Keys of intake entries past their source.ttl — the converge produce
36
- # scope (replaces Domain::IntakeStaleness#call). A ttl-less intake entry
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,
@@ -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)
@@ -6,15 +6,15 @@ module Textus
6
6
 
7
7
  def call
8
8
  declared = collect_declared_handlers
9
- registered = rpc.names(:resolve_handler).to_set
9
+ registered = steps.names(:fetch).to_set
10
10
 
11
11
  out = (declared - registered).map do |name|
12
12
  {
13
13
  "code" => "intake.handler_missing",
14
14
  "level" => "error",
15
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}) { ... } }`",
16
+ "message" => "manifest references intake handler '#{name}' but no fetch step for '#{name}' is registered",
17
+ "fix" => "create .textus/steps/fetch/#{name}.rb with `class #{name.to_s.split(/[-_]/).map(&:capitalize).join}Fetch < Textus::Step::Fetch`",
18
18
  }
19
19
  end
20
20
 
@@ -23,8 +23,8 @@ module Textus
23
23
  "code" => "intake.handler_orphan",
24
24
  "level" => "warning",
25
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}`",
26
+ "message" => "fetch step '#{name}' is registered but no manifest entry references it",
27
+ "fix" => "remove the unused step, or add an entry with `intake.handler: #{name}`",
28
28
  }
29
29
  end
30
30
 
@@ -8,10 +8,10 @@ module Textus
8
8
  # corruption (the accept gate already refuses them).
9
9
  class ProposalTargets < Check
10
10
  def call
11
- queue = manifest.policy.queue_zone
11
+ queue = manifest.policy.queue_lane
12
12
  return [] unless queue
13
13
 
14
- dispatch(:list, zone: queue).filter_map { |row| issue_for(row["key"]) }
14
+ dispatch(:list, lane: queue).filter_map { |row| issue_for(row["key"]) }
15
15
  end
16
16
 
17
17
  private
@@ -20,7 +20,7 @@ module Textus
20
20
  target = dispatch(:get, key).meta&.dig("proposal", "target_key")
21
21
  return nil if target.nil? # not a proposal entry — skip
22
22
 
23
- zone = manifest.resolver.resolve(target).entry.zone
23
+ zone = manifest.resolver.resolve(target).entry.lane
24
24
  return nil if manifest.policy.declared_kind(zone.to_s) == :canon
25
25
 
26
26
  {
@@ -2,7 +2,7 @@ module Textus
2
2
  module Doctor
3
3
  class Check
4
4
  # Flags entries whose key is matched by two or more rule blocks of the
5
- # SAME specificity in the same slot (lifecycle / handler_allowlist /
5
+ # SAME specificity in the same slot (lifecycle / handler_permit /
6
6
  # guard / materialize). Ties are non-deterministic in the parser's pick step, so
7
7
  # they're a configuration smell — surface them.
8
8
  class RuleAmbiguity < Check
@@ -26,7 +26,7 @@ module Textus
26
26
  carriers = matches.select { |b| b.public_send(slot) }
27
27
  return [] if carriers.length < 2
28
28
 
29
- by_specificity = carriers.group_by { |b| Textus::Domain::Policy::Matcher.specificity(b.match) }
29
+ by_specificity = carriers.group_by { |b| Textus::Manifest::Policy::Matcher.specificity(b.match) }
30
30
  tied = by_specificity.values.select { |group| group.length > 1 }
31
31
  tied.map { |group| issue_for(mentry, slot, group) }
32
32
  end
@@ -3,8 +3,14 @@ module Textus
3
3
  class Check
4
4
  class SchemaViolations < Check
5
5
  def call
6
- res = dispatch(:validate_all)
7
- res["violations"].map do |v|
6
+ result = Textus::Doctor::Validator.new(
7
+ reader: ->(key, ctnr, c) { Textus::Action::Get.new(key: key).call(container: ctnr, call: c) },
8
+ manifest: @container.manifest,
9
+ audit_log: @container.audit_log,
10
+ schema_for: ->(name) { @container.schemas.fetch_or_nil(name) },
11
+ ).call(container: @container, call: Textus::Call.build(role: Textus::Role::DEFAULT))
12
+
13
+ result["violations"].map do |v|
8
14
  fix = v["expected"] &&
9
15
  "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
10
16
  {