textus 0.20.2 → 0.26.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +194 -0
  4. data/README.md +8 -5
  5. data/SPEC.md +54 -15
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/{intro.rb → boot.rb} +49 -29
  48. data/lib/textus/builder/pipeline.rb +5 -5
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +4 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +13 -0
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +17 -0
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/cli.rb +1 -1
  83. data/lib/textus/doctor/check/audit_log.rb +2 -2
  84. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -3
  86. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  87. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  88. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  91. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  92. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  93. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  94. data/lib/textus/doctor/check/schemas.rb +2 -2
  95. data/lib/textus/doctor/check/sentinels.rb +2 -2
  96. data/lib/textus/doctor/check/templates.rb +2 -2
  97. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  98. data/lib/textus/doctor/check.rb +5 -3
  99. data/lib/textus/doctor.rb +24 -27
  100. data/lib/textus/domain/authorizer.rb +4 -4
  101. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  102. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  103. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  104. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  105. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  106. data/lib/textus/domain/staleness.rb +1 -1
  107. data/lib/textus/errors.rb +16 -0
  108. data/lib/textus/hooks/builtin.rb +14 -14
  109. data/lib/textus/hooks/context.rb +13 -13
  110. data/lib/textus/hooks/error_log.rb +32 -0
  111. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  112. data/lib/textus/hooks/loader.rb +29 -3
  113. data/lib/textus/hooks/rpc_registry.rb +77 -0
  114. data/lib/textus/infra/audit_log.rb +126 -16
  115. data/lib/textus/infra/audit_subscriber.rb +6 -7
  116. data/lib/textus/infra/refresh/detached.rb +1 -1
  117. data/lib/textus/key/path.rb +7 -3
  118. data/lib/textus/manifest/data.rb +78 -0
  119. data/lib/textus/manifest/entry/base.rb +44 -7
  120. data/lib/textus/manifest/entry/derived.rb +41 -6
  121. data/lib/textus/manifest/entry/intake.rb +15 -3
  122. data/lib/textus/manifest/entry/leaf.rb +6 -5
  123. data/lib/textus/manifest/entry/nested.rb +42 -3
  124. data/lib/textus/manifest/entry/parser.rb +8 -44
  125. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  126. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  127. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  128. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  129. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  130. data/lib/textus/manifest/entry/validators.rb +1 -1
  131. data/lib/textus/manifest/entry.rb +3 -0
  132. data/lib/textus/manifest/policy.rb +48 -0
  133. data/lib/textus/manifest/resolver.rb +18 -18
  134. data/lib/textus/manifest/rules.rb +1 -1
  135. data/lib/textus/manifest/schema.rb +20 -6
  136. data/lib/textus/manifest.rb +53 -101
  137. data/lib/textus/mcp/errors.rb +32 -0
  138. data/lib/textus/mcp/server.rb +127 -0
  139. data/lib/textus/mcp/session.rb +31 -0
  140. data/lib/textus/mcp/tool_schemas.rb +71 -0
  141. data/lib/textus/mcp/tools.rb +129 -0
  142. data/lib/textus/mcp.rb +6 -0
  143. data/lib/textus/schema/tools.rb +14 -10
  144. data/lib/textus/session.rb +84 -0
  145. data/lib/textus/store.rb +17 -8
  146. data/lib/textus/version.rb +1 -1
  147. data/lib/textus.rb +8 -1
  148. metadata +65 -38
  149. data/lib/textus/application/reads/audit.rb +0 -69
  150. data/lib/textus/application/reads/blame.rb +0 -82
  151. data/lib/textus/application/reads/deps.rb +0 -26
  152. data/lib/textus/application/reads/freshness.rb +0 -88
  153. data/lib/textus/application/reads/get.rb +0 -67
  154. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  155. data/lib/textus/application/reads/list.rb +0 -17
  156. data/lib/textus/application/reads/policy_explain.rb +0 -39
  157. data/lib/textus/application/reads/published.rb +0 -17
  158. data/lib/textus/application/reads/rdeps.rb +0 -27
  159. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  160. data/lib/textus/application/reads/stale.rb +0 -15
  161. data/lib/textus/application/reads/uid.rb +0 -23
  162. data/lib/textus/application/reads/validate_all.rb +0 -24
  163. data/lib/textus/application/reads/where.rb +0 -18
  164. data/lib/textus/application/refresh/all.rb +0 -52
  165. data/lib/textus/application/refresh/worker.rb +0 -116
  166. data/lib/textus/application/writes/accept.rb +0 -89
  167. data/lib/textus/application/writes/delete.rb +0 -33
  168. data/lib/textus/application/writes/mv.rb +0 -105
  169. data/lib/textus/application/writes/publish.rb +0 -162
  170. data/lib/textus/application/writes/put.rb +0 -37
  171. data/lib/textus/application/writes/reject.rb +0 -50
  172. data/lib/textus/cli/verb/intro.rb +0 -13
  173. data/lib/textus/infra/event_bus.rb +0 -27
  174. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  175. data/lib/textus/operations.rb +0 -169
@@ -14,12 +14,13 @@ module Textus
14
14
  raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
15
15
  end
16
16
 
17
- has_template = entry.respond_to?(:template) && !entry.template.nil?
18
- is_external = entry.derived? && entry.external?
19
- return unless entry.in_generator_zone? && !has_template && !is_external &&
17
+ has_template = !entry.template.nil?
18
+ is_external = entry.derived? && entry.external?
19
+ is_intake = entry.intake?
20
+ return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
20
21
  %w[markdown text].include?(entry.format) && !entry.nested?
21
22
 
22
- raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
23
+ raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
23
24
  end
24
25
  end
25
26
  end
@@ -4,7 +4,8 @@ module Textus
4
4
  module Validators
5
5
  module IndexFilename
6
6
  def self.call(entry)
7
- index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : entry.raw["index_filename"]
7
+ # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
8
+ index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
8
9
  return if index_filename.nil?
9
10
 
10
11
  check_shape!(entry, index_filename)
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ module InjectBoot
6
+ def self.call(entry)
7
+ return unless entry.inject_boot
8
+
9
+ raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries") unless entry.in_generator_zone?
10
+
11
+ return unless entry.template.nil?
12
+
13
+ raise UsageError.new("entry '#{entry.key}': inject_boot: requires a template:")
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -7,13 +7,14 @@ module Textus
7
7
  VAR_RE = /\{([a-z]+)\}/
8
8
  REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
9
9
 
10
- def self.call(entry) # rubocop:disable Metrics/AbcSize
11
- publish_each = entry.respond_to?(:publish_each) ? entry.publish_each : entry.raw["publish_each"]
10
+ def self.call(entry)
11
+ # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
12
+ publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
12
13
  return if publish_each.nil?
13
14
 
14
15
  raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
15
16
 
16
- publish_to = entry.respond_to?(:publish_to) ? entry.publish_to : Array(entry.raw["publish_to"])
17
+ publish_to = entry.publish_to
17
18
  raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
18
19
  raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
19
20
 
@@ -5,7 +5,7 @@ module Textus
5
5
  REGISTERED = [
6
6
  Events,
7
7
  PublishEach,
8
- InjectIntro,
8
+ InjectBoot,
9
9
  IndexFilename,
10
10
  FormatMatrix,
11
11
  ].freeze
@@ -5,6 +5,9 @@ module Textus
5
5
  # constants on Entry. Canonical source is the PublishEach validator.
6
6
  PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
7
7
  PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
8
+
9
+ # Populated by each Entry::* subclass at load time.
10
+ REGISTRY = {}
8
11
  end
9
12
  end
10
13
  end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ class Manifest
3
+ # Authority over zones and roles derived from a Manifest::Data snapshot.
4
+ # Encapsulates the lookups previously living on Manifest itself
5
+ # (zone_writers, zone_kinds, permission_for, role_kind, roles_with_kind).
6
+ class Policy
7
+ def initialize(data)
8
+ @data = data
9
+ @zone_kinds_cache = {}
10
+ end
11
+
12
+ def zone_writers(zone_name)
13
+ @data.zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
14
+ end
15
+
16
+ def zone_readers
17
+ @data.zone_readers
18
+ end
19
+
20
+ def permission_for(zone_name)
21
+ Textus::Domain::Permission.new(
22
+ zone: zone_name,
23
+ write_policy: zone_writers(zone_name),
24
+ read_policy: @data.zone_readers[zone_name] || :all,
25
+ )
26
+ end
27
+
28
+ def zone_kinds(zone_name)
29
+ @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
30
+ k = role_kind(w)
31
+ acc << k if k
32
+ end.freeze
33
+ end
34
+
35
+ def role_mapping
36
+ @data.role_mapping
37
+ end
38
+
39
+ def role_kind(name)
40
+ @data.role_mapping[name]
41
+ end
42
+
43
+ def roles_with_kind(kind)
44
+ @data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,19 +1,19 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Resolver
4
- Resolution = Data.define(:entry, :path, :remaining)
4
+ Resolution = ::Data.define(:entry, :path, :remaining)
5
5
 
6
- def initialize(manifest)
7
- @manifest = manifest
6
+ def initialize(data)
7
+ @data = data
8
8
  end
9
9
 
10
10
  def resolve(key)
11
- @manifest.validate_key!(key)
11
+ @data.validate_key!(key)
12
12
  segments = key.split(".")
13
- candidates = @manifest.entries
14
- .map { |e| [e, e.key.split(".")] }
15
- .select { |(_, esegs)| esegs == segments[0, esegs.length] }
16
- .sort_by { |(_, esegs)| -esegs.length }
13
+ candidates = @data.entries
14
+ .map { |e| [e, e.key.split(".")] }
15
+ .select { |(_, esegs)| esegs == segments[0, esegs.length] }
16
+ .sort_by { |(_, esegs)| -esegs.length }
17
17
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
18
18
 
19
19
  entry, esegs = candidates.first
@@ -23,7 +23,7 @@ module Textus
23
23
 
24
24
  def suggestions_for(key)
25
25
  candidates = enumerate.map { |r| r[:key] }
26
- candidates.concat(@manifest.entries.reject { |e| nested_entry?(e) }.map(&:key))
26
+ candidates.concat(@data.entries.reject { |e| nested_entry?(e) }.map(&:key))
27
27
  candidates.uniq!
28
28
  Key::Distance.suggest(key, candidates, limit: 5)
29
29
  rescue StandardError
@@ -31,7 +31,7 @@ module Textus
31
31
  end
32
32
 
33
33
  def enumerate(prefix: nil)
34
- out = @manifest.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
34
+ out = @data.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
35
35
  out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
36
36
  out.sort_by { |row| row[:key] }
37
37
  end
@@ -42,7 +42,7 @@ module Textus
42
42
  # entry with nested: true in the raw YAML — e.g. Intake entries covering
43
43
  # a directory of leaf files).
44
44
  def nested_entry?(entry)
45
- entry.is_a?(Textus::Manifest::Entry::Nested) || entry.raw["nested"] == true
45
+ entry.nested?
46
46
  end
47
47
 
48
48
  def build_resolution(entry, remaining, key)
@@ -51,12 +51,12 @@ module Textus
51
51
  else
52
52
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
53
53
 
54
- index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
54
+ index_fn = entry.index_filename
55
55
  path = if index_fn
56
- File.join(@manifest.root, "zones", entry.path, *remaining, index_fn)
56
+ File.join(@data.root, "zones", entry.path, *remaining, index_fn)
57
57
  else
58
58
  primary_ext = Textus::Entry.for_format(entry.format).extensions.first
59
- File.join(@manifest.root, "zones", entry.path, *remaining) + primary_ext
59
+ File.join(@data.root, "zones", entry.path, *remaining) + primary_ext
60
60
  end
61
61
  Resolution.new(entry: entry, path: path, remaining: remaining)
62
62
  end
@@ -68,17 +68,17 @@ module Textus
68
68
  end
69
69
 
70
70
  def enumerate_nested(entry)
71
- base = File.join(@manifest.root, "zones", entry.path)
71
+ base = File.join(@data.root, "zones", entry.path)
72
72
  return [] unless File.directory?(base)
73
73
 
74
- entry_index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : nil
74
+ entry_index_filename = entry.index_filename
75
75
  glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
76
76
  Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
77
77
  end
78
78
 
79
79
  def nested_row_for(entry, base, path)
80
80
  rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
81
- entry_if = entry.respond_to?(:index_filename) ? entry.index_filename : nil
81
+ entry_if = entry.index_filename
82
82
  stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
83
83
  segs = stripped.split("/").reject { |s| s.empty? || s == "." }
84
84
  return nil if segs.empty?
@@ -101,7 +101,7 @@ module Textus
101
101
  end
102
102
 
103
103
  def resolve_leaf_path(entry)
104
- Textus::Key::Path.resolve(@manifest, entry)
104
+ Textus::Key::Path.resolve(@data, entry)
105
105
  end
106
106
 
107
107
  def nested_glob(format)
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Rules
4
- RuleSet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
4
+ RuleSet = ::Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
5
  EMPTY_SET = RuleSet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
6
6
 
7
7
  def self.parse(raw)
@@ -1,14 +1,14 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  module Schema
4
- ROOT_KEYS = %w[version roles zones entries rules].freeze
4
+ ROOT_KEYS = %w[version roles zones entries rules audit].freeze
5
5
  ROLE_KEYS = %w[name kind].freeze
6
6
  ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
7
7
  ZONE_KEYS = %w[name write_policy read_policy].freeze
8
8
  ENTRY_KEYS = %w[
9
9
  key path zone kind schema owner nested format
10
10
  compute template publish_to publish_each
11
- intake events inject_intro index_filename
11
+ intake events inject_boot index_filename
12
12
  ].freeze
13
13
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
14
14
  INTAKE_KEYS = %w[handler config].freeze
@@ -16,22 +16,37 @@ module Textus
16
16
  REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
17
17
  FETCH_TIMEOUT_SECONDS_CEILING = 3600
18
18
  PROMOTION_KEYS = %w[requires].freeze
19
+ AUDIT_KEYS = %w[max_size keep].freeze
19
20
 
20
21
  def self.validate!(raw)
21
22
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
22
23
 
23
24
  walk(raw, ROOT_KEYS, "$")
24
25
  validate_roles!(raw["roles"])
25
- Array(raw["zones"]).each_with_index do |z, i|
26
+ validate_zones!(raw["zones"])
27
+ validate_entries!(raw["entries"])
28
+ validate_rules!(raw["rules"])
29
+ walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
30
+ validate_zone_writers_declared!(raw)
31
+ end
32
+
33
+ def self.validate_zones!(zones)
34
+ Array(zones).each_with_index do |z, i|
26
35
  walk(z, ZONE_KEYS, "$.zones[#{i}]")
27
36
  end
28
- Array(raw["entries"]).each_with_index do |e, i|
37
+ end
38
+
39
+ def self.validate_entries!(entries)
40
+ Array(entries).each_with_index do |e, i|
29
41
  path = "$.entries[#{i}]"
30
42
  walk(e, ENTRY_KEYS, path)
31
43
  walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
32
44
  walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
33
45
  end
34
- Array(raw["rules"]).each_with_index do |r, i|
46
+ end
47
+
48
+ def self.validate_rules!(rules)
49
+ Array(rules).each_with_index do |r, i|
35
50
  path = "$.rules[#{i}]"
36
51
  walk(r, RULE_KEYS, path)
37
52
  if r["refresh"].is_a?(Hash)
@@ -40,7 +55,6 @@ module Textus
40
55
  end
41
56
  walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
42
57
  end
43
- validate_zone_writers_declared!(raw)
44
58
  end
45
59
 
46
60
  def self.validate_zone_writers_declared!(raw)
@@ -1,117 +1,69 @@
1
1
  require "yaml"
2
+
3
+ module Textus
4
+ # Manifest is the composition record for a parsed manifest. It bundles
5
+ # four collaborators:
6
+ #
7
+ # * data — frozen value: raw, root, zones, entries, audit_config, role_mapping
8
+ # * resolver — resolves keys → entry + path
9
+ # * policy — zone/role authority (zone_writers, zone_kinds, permission_for, …)
10
+ # * rules — match-block rule engine (refresh, handler allowlist, promotion, …)
11
+ #
12
+ # Use `manifest.data.entries`, `manifest.policy.zone_kinds(z)`, etc.
13
+ Manifest = Data.define(:data, :resolver, :policy, :rules)
14
+ end
15
+
2
16
  require_relative "manifest/schema"
17
+ require_relative "manifest/data"
18
+ require_relative "manifest/policy"
3
19
  require_relative "manifest/resolver"
4
20
  require_relative "manifest/role_kinds"
5
21
 
6
- module Textus
22
+ # Reopen Textus::Manifest (defined above as a Data.define) to attach
23
+ # class-level loaders and helpers.
24
+ module Textus # rubocop:disable Style/OneClassPerFile
7
25
  class Manifest
8
- attr_reader :root, :entries, :raw
9
-
10
- def zones
11
- @zones ||= Array(@raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
12
- end
13
-
14
- def zone_readers
15
- @zone_readers ||= Array(@raw["zones"]).to_h do |z|
16
- rp = z["read_policy"]
17
- [z["name"], rp.nil? ? :all : Array(rp)]
26
+ class << self
27
+ def parse(yaml_text, root: ".")
28
+ raw = YAML.safe_load(yaml_text, aliases: false)
29
+ check_version!(raw, "<string>")
30
+ build(raw, root)
18
31
  end
19
- end
20
32
 
21
- def zone_writers(zone_name)
22
- zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
23
- end
33
+ def load(root)
34
+ manifest_path = File.join(root, "manifest.yaml")
35
+ raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
24
36
 
25
- def permission_for(zone_name)
26
- Textus::Domain::Permission.new(
27
- zone: zone_name,
28
- write_policy: zone_writers(zone_name),
29
- read_policy: zone_readers[zone_name] || :all,
30
- )
31
- end
32
-
33
- def role_mapping
34
- @role_mapping ||= RoleKinds.resolve(@raw["roles"])
35
- end
36
-
37
- def role_kind(name)
38
- role_mapping[name]
39
- end
40
-
41
- def roles_with_kind(kind)
42
- role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
43
- end
44
-
45
- def zone_kinds(zone_name)
46
- @zone_kinds_cache ||= {}
47
- @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
48
- k = role_kind(w)
49
- acc << k if k
50
- end.freeze
51
- end
52
-
53
- def self.parse(yaml_text, root: ".")
54
- raw = YAML.safe_load(yaml_text, aliases: false)
55
- check_version!(raw, "<string>")
56
- new(root, raw)
57
- end
58
-
59
- def self.load(root)
60
- manifest_path = File.join(root, "manifest.yaml")
61
- raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
62
-
63
- raw = YAML.safe_load_file(manifest_path, aliases: false)
64
- check_version!(raw, manifest_path)
65
- new(root, raw)
66
- end
67
-
68
- def self.check_version!(raw, source)
69
- return if raw["version"] == PROTOCOL
70
-
71
- raise BadFrontmatter.new(
72
- source,
73
- "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
74
- )
75
- end
76
- private_class_method :check_version!
77
-
78
- def initialize(root, raw)
79
- @root = root
80
- @raw = raw
81
- raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
82
-
83
- Schema.validate!(raw)
84
-
85
- @entries = Array(raw["entries"]).map do |e|
86
- entry = Manifest::Entry::Parser.call(self, e)
87
- Manifest::Entry::Validators.run_all(entry)
88
- entry
37
+ raw = YAML.safe_load_file(manifest_path, aliases: false)
38
+ check_version!(raw, manifest_path)
39
+ build(raw, root)
89
40
  end
90
- validate_declared_keys!
91
- end
92
41
 
93
- def rules
94
- @rules ||= Textus::Manifest::Rules.parse(@raw["rules"] || [])
95
- end
96
-
97
- def rules_for(key)
98
- rules.for(key)
99
- end
100
-
101
- def resolver
102
- @resolver ||= Resolver.new(self)
103
- end
104
-
105
- def validate_key!(key)
106
- raise UsageError.new("empty key") if key.nil? || key.empty?
107
-
108
- Key::Grammar.validate!(key)
109
- end
42
+ private
43
+
44
+ def build(raw, root)
45
+ data = Manifest::Data.parse(raw, root: root)
46
+ composition = new(
47
+ data: data,
48
+ resolver: Manifest::Resolver.new(data),
49
+ policy: data.policy,
50
+ rules: Manifest::Rules.parse(raw["rules"] || []),
51
+ )
52
+ # Re-point entries' back-reference from Data to the composition
53
+ # record. Entries call `@manifest.policy.*` / `@manifest.resolver.*`
54
+ # at use time (see Entry::Base, Entry::Nested).
55
+ data.entries.each { |e| e.instance_variable_set(:@manifest, composition) }
56
+ composition
57
+ end
110
58
 
111
- private
59
+ def check_version!(raw, source)
60
+ return if raw["version"] == PROTOCOL
112
61
 
113
- def validate_declared_keys!
114
- @entries.each { |e| validate_key!(e.key) }
62
+ raise BadFrontmatter.new(
63
+ source,
64
+ "unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
65
+ )
66
+ end
115
67
  end
116
68
  end
117
69
  end
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module MCP
3
+ # Manifest fingerprint changed mid-session. Client should re-boot.
4
+ class ContractDrift < Textus::Error
5
+ JSONRPC_CODE = -32_001
6
+
7
+ def initialize(message, details: {})
8
+ super("contract_drift", message, details: details)
9
+ end
10
+ end
11
+
12
+ # Audit cursor fell off the keep window. Client should re-boot and
13
+ # resume from the new latest_seq.
14
+ class CursorExpired < Textus::Error
15
+ JSONRPC_CODE = -32_002
16
+
17
+ def initialize(message, details: {})
18
+ super("cursor_expired", message, details: details)
19
+ end
20
+ end
21
+
22
+ # Tool execution failed (validation, authorization, IO). Wraps an
23
+ # underlying Textus::Error or generic StandardError.
24
+ class ToolError < Textus::Error
25
+ JSONRPC_CODE = -32_000
26
+
27
+ def initialize(message, details: {})
28
+ super("tool_error", message, details: details)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,127 @@
1
+ require "json"
2
+ require "digest"
3
+
4
+ module Textus
5
+ module MCP
6
+ # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
7
+ # message (NDJSON). Holds a single Session for the lifetime of stdin.
8
+ class Server
9
+ PROTOCOL_VERSION = "2024-11-05"
10
+ SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
11
+
12
+ def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
13
+ @store = store
14
+ @stdin = stdin
15
+ @stdout = stdout
16
+ @role = role
17
+ @session = nil
18
+ end
19
+
20
+ def run
21
+ @stdin.each_line do |line|
22
+ line = line.strip
23
+ next if line.empty?
24
+
25
+ handle_line(line)
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def handle_line(line)
32
+ msg = JSON.parse(line)
33
+ rescue JSON::ParserError => e
34
+ emit_error(nil, -32_700, "parse error: #{e.message}")
35
+ else
36
+ dispatch(msg)
37
+ end
38
+
39
+ def dispatch(msg)
40
+ rid = msg["id"]
41
+ case msg["method"]
42
+ when "initialize" then handle_initialize(rid, msg["params"] || {})
43
+ when "tools/list" then handle_tools_list(rid)
44
+ when "tools/call" then handle_tools_call(rid, msg["params"] || {})
45
+ when "ping" then emit_result(rid, {})
46
+ when "shutdown" then emit_result(rid, nil)
47
+ when "notifications/initialized" then nil
48
+ else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
49
+ end
50
+ end
51
+
52
+ def handle_initialize(rid, _params)
53
+ proposer = @store.manifest.policy.roles_with_kind(:proposer).first
54
+ propose_zone = nil
55
+ if proposer
56
+ @store.manifest.data.zones.each do |zname, writers|
57
+ if writers.include?(proposer) && zname.include?("review")
58
+ propose_zone = zname
59
+ break
60
+ end
61
+ end
62
+ end
63
+
64
+ @session = Session.new(
65
+ role: @role,
66
+ cursor: @store.audit_log.latest_seq,
67
+ propose_zone: propose_zone,
68
+ manifest_etag: manifest_etag,
69
+ )
70
+
71
+ emit_result(rid, {
72
+ "protocolVersion" => PROTOCOL_VERSION,
73
+ "serverInfo" => SERVER_INFO,
74
+ "capabilities" => { "tools" => {} },
75
+ })
76
+ end
77
+
78
+ def handle_tools_list(rid)
79
+ emit_result(rid, { "tools" => ToolSchemas.all })
80
+ end
81
+
82
+ def handle_tools_call(rid, params)
83
+ unless @session
84
+ emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
85
+ return
86
+ end
87
+
88
+ @session.check_etag!(manifest_etag)
89
+
90
+ name = params["name"]
91
+ args = params["arguments"] || {}
92
+ result = Tools.call(name, session: @session, store: @store, args: args)
93
+ @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
94
+
95
+ emit_result(rid, {
96
+ "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
97
+ "isError" => false,
98
+ })
99
+ rescue ContractDrift => e
100
+ emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
101
+ rescue CursorExpired => e
102
+ emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
103
+ rescue ToolError => e
104
+ emit_error(rid, ToolError::JSONRPC_CODE, e.message)
105
+ rescue StandardError => e
106
+ emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
107
+ end
108
+
109
+ def manifest_etag
110
+ Digest::SHA256.hexdigest(File.read(File.join(@store.root, "manifest.yaml")))
111
+ end
112
+
113
+ def emit_result(rid, result)
114
+ write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
115
+ end
116
+
117
+ def emit_error(rid, code, message)
118
+ write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
119
+ end
120
+
121
+ def write(obj)
122
+ @stdout.puts(JSON.dump(obj))
123
+ @stdout.flush
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ module MCP
3
+ # Per-connection state held by the server. Immutable; advance_cursor
4
+ # returns a new instance.
5
+ class Session
6
+ attr_reader :role, :cursor, :propose_zone, :manifest_etag
7
+
8
+ def initialize(role:, cursor:, propose_zone:, manifest_etag:)
9
+ @role = role
10
+ @cursor = cursor
11
+ @propose_zone = propose_zone
12
+ @manifest_etag = manifest_etag
13
+ end
14
+
15
+ def advance_cursor(new_cursor)
16
+ self.class.new(
17
+ role: @role, cursor: new_cursor,
18
+ propose_zone: @propose_zone, manifest_etag: @manifest_etag
19
+ )
20
+ end
21
+
22
+ def check_etag!(observed_etag)
23
+ return if observed_etag == @manifest_etag
24
+
25
+ raise ContractDrift.new(
26
+ "manifest changed (was #{@manifest_etag[0, 8]}, now #{observed_etag[0, 8]}); re-run boot",
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end