textus 0.54.2 → 0.55.2

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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -0,0 +1,8 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ module Retention
5
+ end
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ class Sweep < Base
5
+ REQUIRED_ROLE = Textus::Value::Role::AUTOMATION
6
+ TYPE = "sweep"
7
+
8
+ def self.call(container:, call:, scope: {}, key: nil)
9
+ prefix = key || (scope.is_a?(Hash) ? scope["prefix"] : nil)
10
+ lane = scope.is_a?(Hash) ? scope["lane"] : nil
11
+ rows = Retention::Sweep.new(
12
+ manifest: container.manifest,
13
+ file_stat: Textus::Port::Storage::FileStat.new,
14
+ clock: Textus::Port::Clock.new,
15
+ ).call(prefix: prefix, lane: lane)
16
+ Retention::Base.new(container: container, call: call).call(rows)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,64 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ class Worker
5
+ Summary = Struct.new(:completed, :failed, keyword_init: true)
6
+
7
+ def self.for(container:, queue:)
8
+ new(queue: queue, container: container,
9
+ lease_ttl: container.manifest.data.worker_config[:lease_ttl])
10
+ end
11
+
12
+ def initialize(queue:, container:, lease_ttl: 60)
13
+ @queue = queue
14
+ @container = container
15
+ @lease_ttl = lease_ttl
16
+ end
17
+
18
+ def drain(worker_id: "drain-#{Process.pid}")
19
+ completed = 0
20
+ failed = 0
21
+ loop do
22
+ leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
23
+ break unless leased
24
+
25
+ case run_one(leased)
26
+ when :completed then completed += 1
27
+ when :dead_lettered then failed += 1
28
+ end
29
+ end
30
+ Summary.new(completed: completed, failed: failed)
31
+ end
32
+
33
+ def drain_pool(pool: 4)
34
+ summaries = []
35
+ mutex = Mutex.new
36
+ threads = Array.new(pool) do |i|
37
+ Thread.new do
38
+ s = drain(worker_id: "pool-#{Process.pid}-#{i}")
39
+ mutex.synchronize { summaries << s }
40
+ end
41
+ end
42
+ threads.each(&:join)
43
+ Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
44
+ end
45
+
46
+ private
47
+
48
+ def run_one(leased)
49
+ job = leased.job
50
+ klass = Textus::Jobs.fetch(job.type)
51
+ call = Textus::Value::Call.build(
52
+ role: job.role || Textus::Value::Role::AUTOMATION,
53
+ correlation_id: SecureRandom.uuid,
54
+ )
55
+ klass.call(container: @container, call: call, **job.args.transform_keys(&:to_sym))
56
+ @queue.ack(leased)
57
+ :completed
58
+ rescue StandardError => e
59
+ @queue.fail(leased, error: e.message)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,97 @@
1
+ module Textus
2
+ class Store
3
+ class Layout
4
+ RUN = ".state"
5
+ DATA = "data"
6
+ ASSETS = "assets"
7
+
8
+ def initialize(root)
9
+ @root = root
10
+ freeze
11
+ end
12
+
13
+ attr_reader :root
14
+
15
+ # -- data paths --
16
+ def data_root = File.join(@root, DATA)
17
+ def lane_path(lane_name) = File.join(data_root, lane_name.to_s)
18
+
19
+ def entry_path(mentry)
20
+ primary_ext = Format.for(mentry.format).extensions.first
21
+ rel = normalize_relative_path(mentry.path)
22
+ if File.extname(mentry.path) == ""
23
+ File.join(@root, rel + primary_ext)
24
+ else
25
+ File.join(@root, rel)
26
+ end
27
+ end
28
+
29
+ # -- runtime paths --
30
+ def run_root = File.join(@root, RUN)
31
+ def cursor_path(role) = File.join(run_root, "ephemeral", "cursors", role.to_s)
32
+ def lock_path(name) = File.join(run_root, "ephemeral", "locks", "#{name}.lock")
33
+ def audit_dir_path = File.join(run_root, "audit")
34
+ def audit_log_path = File.join(audit_dir_path, "audit.log")
35
+ def audit_rotated_log_path(n) = File.join(audit_dir_path, "audit.log.#{n}")
36
+ def audit_rotated_meta_path(n) = File.join(audit_dir_path, "audit.log.#{n}.meta.json")
37
+ def audit_log_glob = File.join(audit_dir_path, "audit.log.*")
38
+ def sentinels_root = File.join(run_root, "tracking", "sentinels")
39
+ def store_db_path = File.join(run_root, "store.db")
40
+
41
+ # -- asset paths --
42
+ def asset_raw_dir(date_path, zone)
43
+ File.join(@root, ASSETS, "raw", date_path, zone.to_s)
44
+ end
45
+
46
+ def asset_sentinel_path
47
+ File.join(@root, ASSETS, ".gitignore")
48
+ end
49
+
50
+ def asset_resolve(rel_path)
51
+ File.join(@root, ASSETS, rel_path)
52
+ end
53
+
54
+ def archive_path(source_path)
55
+ rel = source_path.delete_prefix("#{@root}/")
56
+ File.join(@root, "archive", rel)
57
+ end
58
+
59
+ # -- config paths --
60
+ def manifest_path = File.join(@root, "manifest.yaml")
61
+ def schemas_dir = File.join(@root, "schemas")
62
+ def schema_path(name) = File.join(schemas_dir, "#{name}.yaml")
63
+ def template_path(name) = File.join(@root, "templates", name)
64
+ def workflow_dir = File.join(@root, "workflows")
65
+ def hooks_dir = File.join(@root, "hooks")
66
+ def schemas_glob = File.join(schemas_dir, "**", "*")
67
+
68
+ # -- gitignore --
69
+ def gitignore_body(untracked_entries: [])
70
+ lines = ["# textus runtime artifacts — safe to delete, never commit",
71
+ "#{RUN}/"]
72
+ unless untracked_entries.empty?
73
+ lines << "# tracked:false entries — protocol-readable, not committed"
74
+ lines.concat(untracked_entries)
75
+ end
76
+ "#{lines.join("\n")}\n"
77
+ end
78
+
79
+ # -- lane boundary (replaces Writer#zone_floor) --
80
+ def lane_floor(path)
81
+ prefix = "#{data_root}/"
82
+ return nil unless path.start_with?(prefix)
83
+
84
+ seg = path.delete_prefix(prefix).split("/").first
85
+ seg && File.join(data_root, seg)
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_relative_path(path)
91
+ return path if path.start_with?("data/")
92
+
93
+ File.join("data", path)
94
+ end
95
+ end
96
+ end
97
+ end
data/lib/textus/store.rb CHANGED
@@ -2,12 +2,9 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  class Store
5
- attr_reader :container
5
+ attr_reader :container, :role, :correlation_id, :cursor, :propose_lane, :contract_etag
6
6
 
7
- # Readers are derived from the Container's schema, so the field set lives
8
- # in exactly one place (Container). A new capability added there is
9
- # automatically exposed on the Store.
10
- Textus::Container.attribute_names.each do |field|
7
+ Textus::Store::Container.attribute_names.each do |field|
11
8
  define_method(field) { @container.public_send(field) }
12
9
  end
13
10
 
@@ -42,50 +39,84 @@ module Textus
42
39
  File.directory?(dir) && File.exist?(File.join(dir, "manifest.yaml"))
43
40
  end
44
41
 
45
- def initialize(root)
46
- @container = build_container(File.expand_path(root))
42
+ def initialize(root, role: Value::Role::DEFAULT, correlation_id: nil, dry_run: false, container: nil)
43
+ @root = File.expand_path(root)
44
+ @container = container || build_container(@root)
45
+ @role = role.to_s
46
+ @correlation_id = correlation_id || SecureRandom.uuid
47
+ @dry_run = dry_run
48
+ build_session!
47
49
  end
48
50
 
49
- # Build an agent Session oriented at the current cursor/manifest — the
50
- # Ruby equivalent of an MCP `initialize`. ADR 0036.
51
- def session(role:)
52
- Textus::Session.new(
53
- role: role.to_s,
54
- cursor: audit_log.latest_seq,
55
- propose_lane: manifest.policy.propose_lane_for(role),
56
- contract_etag: Textus::Etag.for_contract(root),
57
- )
51
+ def dry_run? = @dry_run
52
+
53
+ def with_role(new_role)
54
+ _rebuild(role: new_role)
55
+ end
56
+
57
+ def with_correlation_id(cid)
58
+ _rebuild(correlation_id: cid)
58
59
  end
59
60
 
60
- def gate
61
- @container.gate
61
+ def advance_cursor(new_cursor)
62
+ dup.tap do |s|
63
+ s.instance_variable_set(:@cursor, new_cursor)
64
+ end
62
65
  end
63
66
 
64
- def as(role, dry_run: false, correlation_id: nil)
65
- Textus::Surfaces::RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
67
+ def check_etag!(observed_etag)
68
+ return if observed_etag == @contract_etag
69
+
70
+ raise Textus::ContractDrift.new(
71
+ "contract changed (manifest/hooks/schemas were #{short_etag(@contract_etag)}, " \
72
+ "now #{short_etag(observed_etag)}); re-run boot",
73
+ )
66
74
  end
67
75
 
68
76
  private
69
77
 
78
+ def _rebuild(role: @role, correlation_id: @correlation_id, dry_run: @dry_run)
79
+ self.class.allocate.tap do |s|
80
+ s.instance_variable_set(:@root, @root)
81
+ s.instance_variable_set(:@container, @container)
82
+ s.instance_variable_set(:@role, role.to_s)
83
+ s.instance_variable_set(:@correlation_id, correlation_id || SecureRandom.uuid)
84
+ s.instance_variable_set(:@dry_run, dry_run)
85
+ s.send(:build_session!)
86
+ end
87
+ end
88
+
89
+ def build_session!
90
+ @cursor = @container.audit_log.latest_seq
91
+ @propose_lane = @container.manifest.policy.propose_lane_for(@role)
92
+ @contract_etag = Value::Etag.for_contract(@root)
93
+ end
94
+
95
+ def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
96
+
70
97
  def build_container(root)
71
98
  manifest = Manifest.load(root)
72
- container = Container.new(
73
- root: root,
74
- manifest: manifest,
75
- schemas: Schemas.new(File.join(root, "schemas")),
76
- file_store: Ports::Storage::FileStore.new,
77
- audit_log: Ports::AuditLog.new(
78
- root,
99
+ job_store = Port::Store.new(root: root).setup!
100
+ layout = Store::Layout.new(root)
101
+ infra = Container::Infrastructure.new(
102
+ file_store: Port::Storage::FileStore.new,
103
+ schemas: Schema::Registry.new(layout.schemas_dir),
104
+ audit_log: Port::AuditLog.new(
105
+ layout: layout,
79
106
  max_size: manifest.data.audit_config[:max_size],
80
107
  keep: manifest.data.audit_config[:keep],
81
108
  ),
109
+ job_store:,
110
+ layout:,
111
+ )
112
+
113
+ coord_seed = Container::Coordination.new(
114
+ manifest:,
82
115
  workflows: Workflow::Loader.load_all(root),
83
- gate: nil,
116
+ pipeline: nil,
84
117
  )
85
- gate = Textus::Gate.new(container)
86
- container = container.with(gate: gate)
87
- gate.instance_variable_set(:@container, container)
88
- container
118
+
119
+ Container.build(infra, coord_seed)
89
120
  end
90
121
  end
91
122
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Data < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Key < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class MCP < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Rule < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Schema < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group < Verb
5
5
  class << self
@@ -7,7 +7,7 @@ module Textus
7
7
  # `parent_group` is this group counts as a subcommand. Sorted
8
8
  # alphabetically by command_name for stable help output.
9
9
  def subcommands
10
- Textus::Surfaces::CLI::Runner.install!
10
+ Textus::Surface::CLI::Runner.install!
11
11
  Verb.descendants
12
12
  .select { |k| k.parent_group == self && k.command_name }
13
13
  .sort_by(&:command_name)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  # Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
5
5
  # so the CLI surface is a projection of the contract — the operator-facing
@@ -49,63 +49,23 @@ module Textus
49
49
 
50
50
  module_function
51
51
 
52
- # Build a Command from the spec + parsed inputs, dispatch through Gate.
53
52
  def dispatch(verb_instance, store, spec)
54
- inputs = Textus::Contract::Binder.inputs_from_ordered(
53
+ inputs = Textus::Dispatch::Binder.inputs_from_ordered(
55
54
  spec, verb_instance.positional, verb_instance.flag_values(spec)
56
55
  )
57
- inputs = inputs.merge(Textus::Contract::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
58
- inputs = Textus::Contract::Sources.acquire(spec, inputs)
56
+ inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
57
+ inputs = Surface::CLI::Sources.acquire(spec, inputs)
59
58
  inputs = apply_cli_defaults(spec, inputs)
60
59
  role = verb_instance.resolved_role(store)
61
60
 
62
- invoke = lambda do |effective_inputs|
63
- cmd = build_command(spec, effective_inputs, role)
64
- store.gate.dispatch(cmd)
65
- end
66
-
67
- result = if spec.around
68
- scope = store.as(role)
69
- Textus::Contract::Around.with(spec.around, scope: scope, inputs: inputs, session: nil, &invoke)
70
- else
71
- invoke.call(inputs)
72
- end
73
- verb_instance.emit(shape(spec, result, inputs))
74
- rescue Textus::Contract::MissingArgs => e
61
+ s = store.with_role(role)
62
+ result = s.public_send(spec.verb, **inputs)
63
+ result = spec.view(:cli).call(result, inputs) if spec.view(:cli)
64
+ verb_instance.emit(result)
65
+ rescue Textus::Dispatch::MissingArgs => e
75
66
  raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
76
67
  end
77
68
 
78
- def build_command(spec, inputs, role)
79
- cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
80
- raise Textus::UsageError.new("no Command for verb: #{spec.verb}")
81
- end
82
- defaults = {}
83
- spec.args.each do |a|
84
- next if a.default == :__unset || inputs.key?(a.name)
85
- next if a.default.nil? && a.required
86
-
87
- defaults[a.name] = a.default
88
- end
89
- kwargs = defaults.merge(inputs)
90
- kwargs[:role] = role if cmd_class.members.include?(:role) && !inputs.key?(:role) && spec.verb != :audit
91
- check_missing_args!(spec, cmd_class, kwargs)
92
-
93
- cmd_class.new(**kwargs.slice(*cmd_class.members))
94
- end
95
-
96
- def check_missing_args!(spec, cmd_class, kwargs)
97
- params = cmd_class.instance_method(:initialize).parameters
98
- required = if params == [[:rest]]
99
- cmd_class.members
100
- else
101
- params.select { |t,| t == :keyreq }.map(&:last)
102
- end
103
- missing = required - kwargs.keys
104
- return if missing.empty?
105
-
106
- raise Textus::Contract::MissingArgs.new(spec, missing.map { |m| Struct.new(:wire, :name).new(m.to_s, m) })
107
- end
108
-
109
69
  # Fill CLI-specific defaults (cli_default:) for args the operator did not
110
70
  # pass, where the CLI default diverges from the contract default the agent
111
71
  # surfaces use — e.g. migrate/data_mv apply by default on the CLI but plan
@@ -113,27 +73,19 @@ module Textus
113
73
  # contract, not hidden in a hand class.
114
74
  def apply_cli_defaults(spec, inputs)
115
75
  spec.args.each_with_object(inputs.dup) do |a, h|
116
- next if a.cli_default == :__unset || h.key?(a.name)
76
+ next if a.cli_default.nil? || h.key?(a.name)
117
77
 
118
78
  h[a.name] = a.cli_default
119
79
  end
120
80
  end
121
81
 
122
- # Shape the use-case result for the CLI wire via the verb's :cli view
123
- # (falling back to the default view). The view is called uniformly as
124
- # (result, inputs); an inputs-aware view echoes an input such as the key
125
- # (ADR 0067).
126
- def shape(spec, result, inputs)
127
- Textus::Contract::View.render(spec, :cli, result, inputs)
128
- end
129
-
130
82
  # The default the CLI flag is generated against — `cli_default:` when the
131
83
  # operator-facing default diverges from the contract default the agent
132
84
  # surfaces use, else the contract `default`. This drives boolean flag
133
85
  # polarity so a verb that applies-by-default on the CLI but plans-by-default
134
86
  # for agents (migrate, data_mv) gets a `--dry-run` flag, not `--no-dry-run`.
135
87
  def effective_default(arg)
136
- arg.cli_default == :__unset ? arg.default : arg.cli_default
88
+ arg.cli_default.nil? ? arg.default : arg.cli_default
137
89
  end
138
90
 
139
91
  def flagspec_for(arg)
@@ -151,6 +103,7 @@ module Textus
151
103
  def coerce(arg, raw)
152
104
  return effective_default(arg) != true if arg.type == :boolean
153
105
  return Integer(raw) if arg.type == Integer
106
+ return JSON.parse(raw) if arg.type == Hash
154
107
 
155
108
  raw
156
109
  end
@@ -193,14 +146,7 @@ module Textus
193
146
 
194
147
  def install!
195
148
  @installed ||= {}
196
- Textus::Gate::ROUTES.each_key do |cmd_class|
197
- verb = Textus::Gate::VERB_COMMAND.key(cmd_class)
198
- next unless verb
199
-
200
- action_class = Textus::Gate::ROUTES[cmd_class].first
201
- next unless action_class.respond_to?(:contract?) && action_class.contract?
202
-
203
- spec = action_class.contract
149
+ Textus::VerbRegistry.registered.each do |spec|
204
150
  next unless spec.cli?
205
151
  next if hand_authored?(spec.verb)
206
152
  next if @installed[spec.verb]
@@ -224,7 +170,7 @@ module Textus
224
170
  non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
225
171
 
226
172
  # Anchor the anonymous class to a constant so descendants discovery is
227
- # stable. Name it after the verb under a Generated namespace.
173
+ # stable. Name it under a Generated namespace.
228
174
  const_name = spec.verb.to_s.split("_").map(&:capitalize).join
229
175
  gen = "Gen#{const_name}"
230
176
  Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
@@ -0,0 +1,41 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Surface
5
+ class CLI
6
+ # CLI-only input acquisition. Transforms entries of the uniform `inputs`
7
+ # hash that declare a `source:`/`coerce:`, and builds `inputs` from a
8
+ # `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
9
+ # hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
10
+ # never run there.
11
+ module Sources
12
+ module_function
13
+
14
+ # Apply per-arg :file sources (value is a path -> file contents) and
15
+ # :coerce callables to a by-name inputs hash. Returns a new hash.
16
+ def acquire(spec, inputs)
17
+ spec.args.each_with_object(inputs.dup) do |a, h|
18
+ next unless h.key?(a.name)
19
+
20
+ h[a.name] = File.read(h[a.name]) if a.source == :file
21
+ h[a.name] = a.coerce.call(h[a.name]) if a.coerce
22
+ end
23
+ end
24
+
25
+ # Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
26
+ # envelope keys (wire-names) to arg names.
27
+ def from_stdin(spec, stream)
28
+ return {} unless spec.cli_stdin == :json
29
+
30
+ raw = stream.read.to_s
31
+ return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
32
+
33
+ envelope = JSON.parse(raw)
34
+ spec.args.each_with_object({}) do |a, h|
35
+ h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class Doctor < Verb
@@ -7,11 +7,10 @@ module Textus
7
7
  option :checks, "--check=NAME"
8
8
 
9
9
  def call(store)
10
- cmd = Textus::Command::Doctor.new(
11
- checks: checks&.split(",")&.map(&:strip),
12
- role: resolved_role(store),
13
- )
14
- res = store.gate.dispatch(cmd)
10
+ Textus::VerbRegistry.for(:doctor)
11
+ inputs = { checks: checks&.split(",")&.map(&:strip) }
12
+ s = store.with_role(resolved_role(store))
13
+ res = s.doctor(**inputs)
15
14
  emit(res, exit_code: res["ok"] ? 0 : 1)
16
15
  end
17
16
  end
@@ -1,18 +1,20 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class Get < Runner::Base
6
- self.spec = Textus::Action::Get.contract
6
+ self.spec = Textus::VerbRegistry.for(:get)
7
7
  option :as_flag, "--as=ROLE"
8
8
 
9
9
  def invoke(store)
10
10
  key = positional.shift or raise UsageError.new("get requires a key")
11
- cmd = Textus::Command::Get.new(key: key, role: resolved_role(store))
12
- result = store.gate.dispatch(cmd)
11
+ spec = Textus::VerbRegistry.for(:get)
12
+ s = store.with_role(resolved_role(store))
13
+ result = s.get(key: key)
14
+ result = spec.view(:cli).call(result, { key: key }) if spec.view(:cli)
13
15
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
14
16
 
15
- emit(result.to_h_for_wire)
17
+ emit(result)
16
18
  end
17
19
  end
18
20
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class Init < Verb
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  # Launches the MCP stdio server in the current process. Blocks on stdin;
@@ -13,8 +13,8 @@ module Textus
13
13
  option :as_flag, "--as=ROLE"
14
14
 
15
15
  def call(store)
16
- role = resolved_role(store, default: Textus::Role::AGENT)
17
- Textus::Surfaces::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
16
+ role = resolved_role(store, default: Textus::Value::Role::AGENT)
17
+ Textus::Surface::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
18
18
  0
19
19
  end
20
20
  end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Surface
3
+ class CLI
4
+ class Verb
5
+ class Put < Runner::Base
6
+ self.spec = Textus::VerbRegistry.for(:put)
7
+ option :as_flag, "--as=ROLE"
8
+ option :use_stdin, "--stdin"
9
+
10
+ def invoke(store)
11
+ key = positional.shift or raise UsageError.new("put requires a key")
12
+ raise UsageError.new("put requires --stdin in v1") unless use_stdin
13
+
14
+ payload = JSON.parse(@stdin.read)
15
+ spec = Textus::VerbRegistry.for(:put)
16
+ inputs = { key: key, meta: payload["_meta"] || {}, body: payload["body"] || "",
17
+ content: nil, if_etag: payload["if_etag"] }
18
+ s = store.with_role(resolved_role(store))
19
+ result = s.put(**inputs)
20
+ result = spec.view(:cli).call(result, inputs) if spec.view(:cli)
21
+ emit(result)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class SchemaDiff < Verb