textus 0.55.1 → 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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
@@ -13,6 +13,11 @@ module Textus
13
13
  "proposal.rejected" => %w[materialize],
14
14
  }.freeze
15
15
 
16
+ ENTRY_LEVEL_TRIGGERS = %w[
17
+ entry.written entry.deleted entry.moved
18
+ proposal.accepted proposal.rejected
19
+ ].freeze
20
+
16
21
  SCOPE_RESOLVERS = {
17
22
  "materialize" => :producible_keys,
18
23
  "sweep" => :lane_keys,
@@ -37,21 +42,21 @@ module Textus
37
42
  end
38
43
 
39
44
  def plan(trigger:, role:)
40
- type = trigger["type"] || trigger[:type]
41
- trigger["target"] || trigger[:target]
45
+ type = trigger["type"] || trigger[:type]
46
+ target = trigger["target"] || trigger[:target]
42
47
  return [] if type.nil?
43
48
 
44
49
  blocks_with_react = @manifest.rules.blocks.select(&:react)
45
50
  if blocks_with_react.any?
46
- plan_from_rules(blocks_with_react, type, role)
51
+ plan_from_rules(blocks_with_react, type, role, target: target)
47
52
  else
48
- plan_from_defaults(type, role)
53
+ plan_from_defaults(type, role, target: target)
49
54
  end
50
55
  end
51
56
 
52
57
  private
53
58
 
54
- def plan_from_rules(blocks, type, role)
59
+ def plan_from_rules(blocks, type, role, target: nil) # rubocop:disable Lint/UnusedMethodArgument
55
60
  jobs = []
56
61
  blocks
57
62
  .select { |b| matches_trigger?(b.react, type) }
@@ -70,10 +75,13 @@ module Textus
70
75
  jobs
71
76
  end
72
77
 
73
- def plan_from_defaults(type, role)
78
+ def plan_from_defaults(type, role, target: nil)
74
79
  actions = ACTIONS_BY_TRIGGER.fetch(type, [])
75
80
  jobs = []
76
- producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
81
+ if actions.include?("materialize")
82
+ keys = target && ENTRY_LEVEL_TRIGGERS.include?(type) ? dependent_keys(target) : producible_keys(nil)
83
+ keys.each { |k| jobs << job("materialize", k, role) }
84
+ end
77
85
  GLOBAL_ACTIONS.each do |action, args|
78
86
  jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: args, role: role) if actions.include?(action)
79
87
  end
@@ -98,6 +106,18 @@ module Textus
98
106
  def lane_keys(_target)
99
107
  @manifest.data.entries.map(&:key)
100
108
  end
109
+
110
+ def dependent_keys(target_key)
111
+ @manifest.data.entries
112
+ .select(&:external?)
113
+ .select do |e|
114
+ Array(e.source&.sources).compact.any? do |s|
115
+ target_key == s || target_key.start_with?("#{s}.")
116
+ end
117
+ end
118
+ .select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
119
+ .map(&:key)
120
+ end
101
121
  end
102
122
  end
103
123
  end
@@ -44,11 +44,19 @@ module Textus
44
44
 
45
45
  def enqueue(job)
46
46
  now = iso_now
47
- @store.execute(
47
+ inserted = @store.execute(
48
48
  "INSERT OR IGNORE INTO jobs (id, type, args, state, role, attempts, max_attempts, errors, lease, created_at, updated_at)
49
49
  VALUES (?, ?, ?, 'ready', ?, ?, ?, ?, NULL, ?, ?)",
50
50
  [job.id, job.type, JSON.dump(job.args), job.role, job.attempts, job.max_attempts, JSON.dump(job.errors), now, now],
51
51
  )
52
+ return inserted if @store.query_value("SELECT changes()")&.to_i&.positive?
53
+
54
+ @store.execute(
55
+ "UPDATE jobs
56
+ SET state = 'ready', role = ?, args = ?, attempts = 0, errors = ?, lease = NULL, max_attempts = ?, updated_at = ?
57
+ WHERE id = ? AND state IN ('done', 'failed')",
58
+ [job.role, JSON.dump(job.args), JSON.dump([]), job.max_attempts, now, job.id],
59
+ )
52
60
  end
53
61
 
54
62
  def ready_ids
@@ -0,0 +1,52 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ module Retention
7
+ class Base
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ end
12
+
13
+ def call(rows)
14
+ out = { dropped: [], archived: [], failed: [] }
15
+ rows.each do |row|
16
+ key = row["key"]
17
+ begin
18
+ case row["action"]
19
+ when "drop"
20
+ delete(key)
21
+ out[:dropped] << key
22
+ when "archive"
23
+ archive_leaf(row)
24
+ delete(key)
25
+ out[:archived] << key
26
+ end
27
+ rescue Textus::Error => e
28
+ out[:failed] << { "key" => key, "error" => e.message }
29
+ end
30
+ end
31
+ out
32
+ end
33
+
34
+ private
35
+
36
+ def archive_leaf(row)
37
+ src = row["path"]
38
+ dest = @container.layout.archive_path(src)
39
+ FileUtils.mkdir_p(File.dirname(dest))
40
+ FileUtils.cp(src, dest)
41
+ end
42
+
43
+ def delete(key)
44
+ mentry = @container.manifest.resolver.resolve(key).entry
45
+ writer = Textus::Store::Entry::Writer.from(container: @container, call: @call)
46
+ writer.delete(key, mentry: mentry)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ module Retention
7
+ class Sweep
8
+ def self.expired?(ttl_seconds:, mtime:, now:)
9
+ return false if ttl_seconds.nil? || mtime.nil?
10
+
11
+ (now - mtime).to_i > ttl_seconds
12
+ end
13
+
14
+ def initialize(manifest:, file_stat:, clock:)
15
+ @manifest = manifest
16
+ @file_stat = file_stat
17
+ @clock = clock
18
+ end
19
+
20
+ def call(prefix: nil, lane: nil)
21
+ @manifest.data.entries
22
+ .select { |m| matches?(m, prefix: prefix, lane: lane) }
23
+ .flat_map { |m| rows_for(m) }
24
+ end
25
+
26
+ private
27
+
28
+ def matches?(mentry, prefix:, lane:)
29
+ return false if lane && mentry.lane != lane
30
+ return false if prefix && !Textus::Key::Matching.matches_prefix?(
31
+ mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
32
+ )
33
+
34
+ true
35
+ end
36
+
37
+ def rows_for(mentry)
38
+ policy = @manifest.rules.for(mentry.key).retention
39
+ return [] if policy.nil?
40
+
41
+ @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
42
+ path = row[:path]
43
+ next unless @file_stat.exists?(path)
44
+ next unless self.class.expired?(
45
+ ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
46
+ )
47
+
48
+ { "key" => row[:key], "path" => path, "action" => policy.action.to_s }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,49 +1,7 @@
1
- require "fileutils"
2
-
3
1
  module Textus
4
2
  class Store
5
3
  module Jobs
6
- class Retention
7
- def initialize(container:, call:)
8
- @container = container
9
- @call = call
10
- end
11
-
12
- def call(rows)
13
- out = { dropped: [], archived: [], failed: [] }
14
- rows.each do |row|
15
- key = row["key"]
16
- begin
17
- case row["action"]
18
- when "drop"
19
- delete(key)
20
- out[:dropped] << key
21
- when "archive"
22
- archive_leaf(row)
23
- delete(key)
24
- out[:archived] << key
25
- end
26
- rescue Textus::Error => e
27
- out[:failed] << { "key" => key, "error" => e.message }
28
- end
29
- end
30
- out
31
- end
32
-
33
- private
34
-
35
- def archive_leaf(row)
36
- src = row["path"]
37
- root = @container.root.to_s
38
- rel = src.delete_prefix("#{root}/")
39
- dest = File.join(root, "archive", rel)
40
- FileUtils.mkdir_p(File.dirname(dest))
41
- FileUtils.cp(src, dest)
42
- end
43
-
44
- def delete(key)
45
- Textus::Action::KeyDelete.call(container: @container, call: @call, key: key)
46
- end
4
+ module Retention
47
5
  end
48
6
  end
49
7
  end
@@ -8,12 +8,12 @@ module Textus
8
8
  def self.call(container:, call:, scope: {}, key: nil)
9
9
  prefix = key || (scope.is_a?(Hash) ? scope["prefix"] : nil)
10
10
  lane = scope.is_a?(Hash) ? scope["lane"] : nil
11
- rows = Textus::Core::Retention::Sweep.new(
11
+ rows = Retention::Sweep.new(
12
12
  manifest: container.manifest,
13
13
  file_stat: Textus::Port::Storage::FileStat.new,
14
14
  clock: Textus::Port::Clock.new,
15
15
  ).call(prefix: prefix, lane: lane)
16
- Textus::Store::Jobs::Retention.new(container: container, call: call).call(rows)
16
+ Retention::Base.new(container: container, call: call).call(rows)
17
17
  end
18
18
  end
19
19
  end
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  class Store
3
- class Geometry
3
+ class Layout
4
4
  RUN = ".state"
5
5
  DATA = "data"
6
6
  ASSETS = "assets"
@@ -32,12 +32,28 @@ module Textus
32
32
  def lock_path(name) = File.join(run_root, "ephemeral", "locks", "#{name}.lock")
33
33
  def audit_dir_path = File.join(run_root, "audit")
34
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.*")
35
38
  def sentinels_root = File.join(run_root, "tracking", "sentinels")
36
39
  def store_db_path = File.join(run_root, "store.db")
37
40
 
38
41
  # -- asset paths --
39
- def asset_path(kind, date_str, zone, filename)
40
- File.join(@root, ASSETS, kind, date_str, zone.to_s, filename)
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)
41
57
  end
42
58
 
43
59
  # -- config paths --
data/lib/textus/store.rb CHANGED
@@ -2,11 +2,8 @@ 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
7
  Textus::Store::Container.attribute_names.each do |field|
11
8
  define_method(field) { @container.public_send(field) }
12
9
  end
@@ -42,58 +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::Store::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::Value::Etag.for_contract(root),
57
- )
51
+ def dry_run? = @dry_run
52
+
53
+ def with_role(new_role)
54
+ _rebuild(role: new_role)
58
55
  end
59
56
 
60
- def gate
61
- @container.gate
57
+ def with_correlation_id(cid)
58
+ _rebuild(correlation_id: cid)
62
59
  end
63
60
 
64
- def as(role, dry_run: false, correlation_id: nil)
65
- Textus::Surface::RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
61
+ def advance_cursor(new_cursor)
62
+ dup.tap do |s|
63
+ s.instance_variable_set(:@cursor, new_cursor)
64
+ end
65
+ end
66
+
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
99
  job_store = Port::Store.new(root: root).setup!
73
- geometry = Store::Geometry.new(root)
100
+ layout = Store::Layout.new(root)
74
101
  infra = Container::Infrastructure.new(
75
102
  file_store: Port::Storage::FileStore.new,
76
- schemas: Schemas.new(geometry.schemas_dir),
103
+ schemas: Schema::Registry.new(layout.schemas_dir),
77
104
  audit_log: Port::AuditLog.new(
78
- root,
105
+ layout: layout,
79
106
  max_size: manifest.data.audit_config[:max_size],
80
107
  keep: manifest.data.audit_config[:keep],
81
108
  ),
82
109
  job_store:,
83
- geometry:,
110
+ layout:,
84
111
  )
85
112
 
86
- coord = Container::Coordination.new(
113
+ coord_seed = Container::Coordination.new(
87
114
  manifest:,
88
115
  workflows: Workflow::Loader.load_all(root),
89
- gate: nil,
90
- compositor: nil,
116
+ pipeline: nil,
91
117
  )
92
118
 
93
- container = Container.new(infra, coord)
94
- compositor = Store::Compositor.new(container)
95
- gate = Textus::Gate.new(container)
96
- container.wire_gate!(gate, compositor)
119
+ Container.build(infra, coord_seed)
97
120
  end
98
121
  end
99
122
  end
@@ -50,7 +50,7 @@ module Textus
50
50
  module_function
51
51
 
52
52
  def dispatch(verb_instance, store, spec)
53
- inputs = Textus::Gate::Binder.inputs_from_ordered(
53
+ inputs = Textus::Dispatch::Binder.inputs_from_ordered(
54
54
  spec, verb_instance.positional, verb_instance.flag_values(spec)
55
55
  )
56
56
  inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
@@ -58,9 +58,11 @@ module Textus
58
58
  inputs = apply_cli_defaults(spec, inputs)
59
59
  role = verb_instance.resolved_role(store)
60
60
 
61
- result = store.gate.dispatch(spec:, inputs:, role:, surface: :cli)
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)
62
64
  verb_instance.emit(result)
63
- rescue Textus::Gate::MissingArgs => e
65
+ rescue Textus::Dispatch::MissingArgs => e
64
66
  raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
65
67
  end
66
68
 
@@ -71,7 +73,7 @@ module Textus
71
73
  # contract, not hidden in a hand class.
72
74
  def apply_cli_defaults(spec, inputs)
73
75
  spec.args.each_with_object(inputs.dup) do |a, h|
74
- next if a.cli_default == :__unset || h.key?(a.name)
76
+ next if a.cli_default.nil? || h.key?(a.name)
75
77
 
76
78
  h[a.name] = a.cli_default
77
79
  end
@@ -83,7 +85,7 @@ module Textus
83
85
  # polarity so a verb that applies-by-default on the CLI but plans-by-default
84
86
  # for agents (migrate, data_mv) gets a `--dry-run` flag, not `--no-dry-run`.
85
87
  def effective_default(arg)
86
- arg.cli_default == :__unset ? arg.default : arg.cli_default
88
+ arg.cli_default.nil? ? arg.default : arg.cli_default
87
89
  end
88
90
 
89
91
  def flagspec_for(arg)
@@ -144,10 +146,7 @@ module Textus
144
146
 
145
147
  def install!
146
148
  @installed ||= {}
147
- Textus::Action::VERBS.each_value do |action_class|
148
- next unless action_class.respond_to?(:contract?) && action_class.contract?
149
-
150
- spec = action_class.contract
149
+ Textus::VerbRegistry.registered.each do |spec|
151
150
  next unless spec.cli?
152
151
  next if hand_authored?(spec.verb)
153
152
  next if @installed[spec.verb]
@@ -7,9 +7,10 @@ module Textus
7
7
  option :checks, "--check=NAME"
8
8
 
9
9
  def call(store)
10
- spec = Textus::Action::Doctor.contract
10
+ Textus::VerbRegistry.for(:doctor)
11
11
  inputs = { checks: checks&.split(",")&.map(&:strip) }
12
- res = store.gate.dispatch(spec: spec, inputs: inputs, role: resolved_role(store))
12
+ s = store.with_role(resolved_role(store))
13
+ res = s.doctor(**inputs)
13
14
  emit(res, exit_code: res["ok"] ? 0 : 1)
14
15
  end
15
16
  end
@@ -3,13 +3,15 @@ module Textus
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
- spec = Textus::Action::Get.contract
12
- result = store.gate.dispatch(spec: spec, inputs: { key: key }, role: resolved_role(store), surface: :cli)
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
17
  emit(result)
@@ -3,7 +3,7 @@ module Textus
3
3
  class CLI
4
4
  class Verb
5
5
  class Put < Runner::Base
6
- self.spec = Textus::Action::Put.contract
6
+ self.spec = Textus::VerbRegistry.for(:put)
7
7
  option :as_flag, "--as=ROLE"
8
8
  option :use_stdin, "--stdin"
9
9
 
@@ -12,10 +12,12 @@ module Textus
12
12
  raise UsageError.new("put requires --stdin in v1") unless use_stdin
13
13
 
14
14
  payload = JSON.parse(@stdin.read)
15
- spec = Textus::Action::Put.contract
15
+ spec = Textus::VerbRegistry.for(:put)
16
16
  inputs = { key: key, meta: payload["_meta"] || {}, body: payload["body"] || "",
17
17
  content: nil, if_etag: payload["if_etag"] }
18
- result = store.gate.dispatch(spec: spec, inputs: inputs, role: resolved_role(store), surface: :cli)
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)
19
21
  emit(result)
20
22
  end
21
23
  end
@@ -1,89 +1,53 @@
1
1
  module Textus
2
2
  module Surface
3
3
  module MCP
4
- # Derives the entire MCP tool surface from the per-verb contracts (ADR 0039).
5
- # `build_tools` builds MCP::Tool instances for the SDK; `call` is the generic
6
- # dispatch: map JSON args -> (positional, keyword) per the contract, invoke
7
- # the verb through the role scope, then shape the return value. No per-tool code.
8
4
  module Catalog
9
- PROJECTOR = Projector.new(view_key: :default, binder_method: :inputs_from_wire).freeze
5
+ PROJECTOR = Projector.new(view_key: :default).freeze
10
6
 
11
7
  module_function
12
8
 
13
- WRITE_VERBS = %i[
14
- put propose key_delete key_mv accept reject enqueue
15
- ].freeze
16
-
17
- MAINTENANCE_VERBS = %i[
18
- data_mv key_mv_prefix key_delete_prefix drain rule_lint
19
- ].freeze
20
-
21
- # Contracts of every MCP-surfaced verb, in Dispatcher order.
22
9
  def specs
23
- Textus::Action::VERBS.values
24
- .select { |k| mcp_surfaced?(k) }
25
- .map(&:contract)
10
+ VerbRegistry.registered.select(&:mcp?)
26
11
  end
27
12
 
28
- # Builds MCP::Tool instances for the SDK, bound to mcp_server.dispatch.
29
13
  def build_tools(mcp_server)
30
- Textus::Action::VERBS
31
- .select { |_, klass| mcp_surfaced?(klass) }
32
- .map do |name, action|
33
- schema = action.contract.input_schema
34
- schema = schema.reject { |k, v| k == :required && Array(v).empty? }
35
- ::MCP::Tool.define(
36
- name: name.to_s,
37
- description: action.contract.summary,
38
- input_schema: schema,
39
- ) do |server_context:, **args|
40
- mcp_server.dispatch(name, args, server_context)
41
- end
14
+ specs.map do |spec|
15
+ schema = spec.input_schema
16
+ schema = schema.reject { |k, v| k == :required && Array(v).empty? }
17
+ ::MCP::Tool.define(
18
+ name: spec.verb.to_s,
19
+ description: spec.summary,
20
+ input_schema: schema,
21
+ ) do |server_context:, **args|
22
+ mcp_server.dispatch(spec.verb, args, server_context)
42
23
  end
24
+ end
43
25
  end
44
26
 
45
27
  def names
46
- PROJECTOR.names(
47
- Textus::Action::VERBS.select { |_, klass| mcp_surfaced?(klass) },
48
- )
28
+ specs.map(&:verb).map(&:to_s)
49
29
  end
50
30
 
51
- # MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
52
- # real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
53
- # from this so it can never advertise a verb the agent cannot call, nor
54
- # omit one it can (ADR 0056). Excludes write/maintenance verbs by verb
55
- # identity (routing may be legacy UseCases or Dispatch::Actions).
56
31
  def read_verbs
57
- Textus::Action::VERBS
58
- .reject { |verb, _klass| WRITE_VERBS.include?(verb) || MAINTENANCE_VERBS.include?(verb) }
59
- .select { |_verb, klass| mcp_surfaced?(klass) }
60
- .keys.map(&:to_s)
32
+ VerbRegistry.registered
33
+ .select { |s| s.read? && s.mcp? }
34
+ .map { |s| s.verb.to_s }
61
35
  end
62
36
 
63
- # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
64
- # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
65
- # from this so it advertises bare verb names the agent can call (no `--as`/
66
- # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
67
- # (ADR 0056, ADR 0057).
68
37
  def write_verbs
69
- Textus::Action::VERBS
70
- .select { |verb, klass| WRITE_VERBS.include?(verb) && mcp_surfaced?(klass) }
71
- .keys.map(&:to_s)
72
- end
73
-
74
- def mcp_surfaced?(klass)
75
- klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
38
+ VerbRegistry.registered
39
+ .select { |s| s.write? && s.mcp? }
40
+ .map { |s| s.verb.to_s }
76
41
  end
77
42
 
78
- def call(name, session:, store:, args:)
79
- klass = Textus::Action::VERBS[name.to_sym]
80
- raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
43
+ def call(name, store:, args:)
44
+ spec = VerbRegistry.for(name.to_sym)
45
+ raise ToolError.new("unknown tool: #{name}") unless spec&.mcp?
81
46
 
82
- PROJECTOR.dispatch(name, inputs: args, store:, role: session.role, session:)
83
- rescue Textus::Gate::MissingArgs => e
84
- spec = klass.contract
85
- raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
86
- rescue Textus::ContractDrift, CursorExpired
47
+ PROJECTOR.dispatch(name, inputs: args, store:)
48
+ rescue Textus::Dispatch::MissingArgs => e
49
+ raise ToolError.new("#{name}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
50
+ rescue Textus::ContractDrift, Textus::CursorExpired
87
51
  raise
88
52
  rescue Textus::Error => e
89
53
  raise ToolError.new("#{name}: #{e.message}")