textus 0.22.0 → 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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +102 -0
  4. data/README.md +1 -1
  5. data/SPEC.md +12 -12
  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/boot.rb +27 -29
  48. data/lib/textus/builder/pipeline.rb +3 -3
  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 +2 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +1 -1
  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 +1 -1
  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/doctor/check/audit_log.rb +2 -2
  83. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  84. data/lib/textus/doctor/check/hooks.rb +4 -3
  85. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  86. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  87. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  88. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  89. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  90. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  91. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  92. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  93. data/lib/textus/doctor/check/schemas.rb +2 -2
  94. data/lib/textus/doctor/check/sentinels.rb +2 -2
  95. data/lib/textus/doctor/check/templates.rb +2 -2
  96. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  97. data/lib/textus/doctor/check.rb +5 -3
  98. data/lib/textus/doctor.rb +24 -27
  99. data/lib/textus/domain/authorizer.rb +4 -4
  100. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  101. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  102. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  103. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  104. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  105. data/lib/textus/domain/staleness.rb +1 -1
  106. data/lib/textus/hooks/builtin.rb +14 -14
  107. data/lib/textus/hooks/context.rb +13 -13
  108. data/lib/textus/hooks/error_log.rb +32 -0
  109. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  110. data/lib/textus/hooks/loader.rb +29 -3
  111. data/lib/textus/hooks/rpc_registry.rb +77 -0
  112. data/lib/textus/infra/audit_subscriber.rb +6 -7
  113. data/lib/textus/infra/refresh/detached.rb +1 -1
  114. data/lib/textus/key/path.rb +7 -3
  115. data/lib/textus/manifest/data.rb +78 -0
  116. data/lib/textus/manifest/entry/base.rb +4 -4
  117. data/lib/textus/manifest/entry/derived.rb +4 -5
  118. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  119. data/lib/textus/manifest/policy.rb +48 -0
  120. data/lib/textus/manifest/resolver.rb +14 -14
  121. data/lib/textus/manifest/rules.rb +1 -1
  122. data/lib/textus/manifest.rb +53 -111
  123. data/lib/textus/mcp/errors.rb +32 -0
  124. data/lib/textus/mcp/server.rb +127 -0
  125. data/lib/textus/mcp/session.rb +31 -0
  126. data/lib/textus/mcp/tool_schemas.rb +71 -0
  127. data/lib/textus/mcp/tools.rb +129 -0
  128. data/lib/textus/mcp.rb +6 -0
  129. data/lib/textus/schema/tools.rb +14 -10
  130. data/lib/textus/session.rb +84 -0
  131. data/lib/textus/store.rb +14 -9
  132. data/lib/textus/version.rb +1 -1
  133. data/lib/textus.rb +8 -1
  134. metadata +61 -36
  135. data/lib/textus/application/reads/audit.rb +0 -94
  136. data/lib/textus/application/reads/blame.rb +0 -82
  137. data/lib/textus/application/reads/deps.rb +0 -26
  138. data/lib/textus/application/reads/freshness.rb +0 -88
  139. data/lib/textus/application/reads/get.rb +0 -67
  140. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  141. data/lib/textus/application/reads/list.rb +0 -17
  142. data/lib/textus/application/reads/policy_explain.rb +0 -39
  143. data/lib/textus/application/reads/published.rb +0 -17
  144. data/lib/textus/application/reads/pulse.rb +0 -63
  145. data/lib/textus/application/reads/rdeps.rb +0 -27
  146. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  147. data/lib/textus/application/reads/stale.rb +0 -15
  148. data/lib/textus/application/reads/uid.rb +0 -23
  149. data/lib/textus/application/reads/validate_all.rb +0 -24
  150. data/lib/textus/application/reads/where.rb +0 -18
  151. data/lib/textus/application/refresh/all.rb +0 -52
  152. data/lib/textus/application/refresh/worker.rb +0 -116
  153. data/lib/textus/application/writes/accept.rb +0 -89
  154. data/lib/textus/application/writes/delete.rb +0 -33
  155. data/lib/textus/application/writes/mv.rb +0 -105
  156. data/lib/textus/application/writes/publish.rb +0 -81
  157. data/lib/textus/application/writes/put.rb +0 -37
  158. data/lib/textus/application/writes/reject.rb +0 -50
  159. data/lib/textus/infra/event_bus.rb +0 -27
  160. data/lib/textus/operations.rb +0 -176
@@ -7,23 +7,23 @@ module Textus
7
7
  module Hooks
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
- def self.register_all(bus)
11
- bus.on(:resolve_intake, :json) do |store:, config:, args:|
12
- _ = store
10
+ def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
11
+ rpc.register(:resolve_intake, :json) do |caps:, config:, args:|
12
+ _ = caps
13
13
  _ = args
14
14
  data = JSON.parse(config["bytes"].to_s)
15
15
  { _meta: {}, body: YAML.dump(data) }
16
16
  end
17
17
 
18
- bus.on(:resolve_intake, :csv) do |store:, config:, args:|
19
- _ = store
18
+ rpc.register(:resolve_intake, :csv) do |caps:, config:, args:|
19
+ _ = caps
20
20
  _ = args
21
21
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
22
  { _meta: {}, body: YAML.dump(rows) }
23
23
  end
24
24
 
25
- bus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
26
- _ = store
25
+ rpc.register(:resolve_intake, :"markdown-links") do |caps:, config:, args:|
26
+ _ = caps
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
29
29
  { "text" => text, "href" => href }
@@ -31,27 +31,27 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- bus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
35
- _ = store
34
+ rpc.register(:resolve_intake, :"ical-events") do |caps:, config:, args:|
35
+ _ = caps
36
36
  _ = args
37
- events = []
37
+ events_list = []
38
38
  current = nil
39
39
  config["bytes"].to_s.each_line do |line|
40
40
  line = line.strip
41
41
  case line
42
42
  when "BEGIN:VEVENT" then current = {}
43
43
  when "END:VEVENT"
44
- events << current if current
44
+ events_list << current if current
45
45
  current = nil
46
46
  when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
47
47
  current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
48
48
  end
49
49
  end
50
- { _meta: {}, body: YAML.dump(events) }
50
+ { _meta: {}, body: YAML.dump(events_list) }
51
51
  end
52
52
 
53
- bus.on(:resolve_intake, :rss) do |store:, config:, args:|
54
- _ = store
53
+ rpc.register(:resolve_intake, :rss) do |caps:, config:, args:|
54
+ _ = caps
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
57
57
  items = doc.elements.to_a("//item").map do |item|
@@ -3,31 +3,31 @@
3
3
  module Textus
4
4
  module Hooks
5
5
  # A narrow handle passed to user hooks in place of the raw Store.
6
- # All writes route back through Operations so authorization, audit
6
+ # All writes route back through the Session so authorization, audit
7
7
  # logging, and schema validation always fire.
8
8
  class Context
9
9
  attr_reader :role, :correlation_id
10
10
 
11
- def initialize(ops:)
12
- @ops = ops
13
- @role = ops.ctx.role
14
- @correlation_id = ops.ctx.correlation_id
11
+ def initialize(session:)
12
+ @session = session
13
+ @role = session.ctx.role
14
+ @correlation_id = session.ctx.correlation_id
15
15
  end
16
16
 
17
17
  # read
18
- def get(key) = @ops.get(key)
19
- def list(**) = @ops.list(**)
20
- def deps(key) = @ops.deps(key)
21
- def freshness(key) = @ops.freshness(key)
18
+ def get(key) = @session.get(key)
19
+ def list(**) = @session.list(**)
20
+ def deps(key) = @session.deps(key)
21
+ def freshness(key) = @session.freshness(key)
22
22
 
23
23
  # write (authorized + audited)
24
- def put(key, **) = @ops.put(key, **)
25
- def delete(key, **) = @ops.delete(key, **)
26
- def audit(verb, key:, **) = @ops.store.audit_log.append(role: @role, verb: verb, key: key, **)
24
+ def put(key, **) = @session.put(key, **)
25
+ def delete(key, **) = @session.delete(key, **)
26
+ def audit(verb, key:, **) = @session.write_caps.audit_log.append(role: @role, verb: verb, key: key, **)
27
27
 
28
28
  # fan-out
29
29
  def publish_followup(event, **)
30
- @ops.store.bus.publish(event, ctx: self, **)
30
+ @session.write_caps.events.publish(event, ctx: self, **)
31
31
  end
32
32
 
33
33
  def inspect
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module Hooks
3
+ # Bounded in-memory ring buffer of recent hook failures (errored and
4
+ # timed_out). Each row carries the audit `seq` observed at the time of
5
+ # failure so pulse can filter "errors since cursor".
6
+ class ErrorLog
7
+ DEFAULT_CAPACITY = 256
8
+
9
+ def initialize(capacity: DEFAULT_CAPACITY)
10
+ @capacity = capacity
11
+ @rows = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def record(seq:, event:, hook:, key:, error_class:, error_message:)
16
+ row = {
17
+ seq: seq, event: event, hook: hook, key: key,
18
+ error_class: error_class, error_message: error_message,
19
+ at: Time.now.utc.iso8601
20
+ }
21
+ @mutex.synchronize do
22
+ @rows << row
23
+ @rows.shift while @rows.size > @capacity
24
+ end
25
+ end
26
+
27
+ def since(seq)
28
+ @mutex.synchronize { @rows.select { |r| r[:seq] > seq }.dup }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -2,67 +2,55 @@
2
2
 
3
3
  module Textus
4
4
  module Hooks
5
- class Bus
5
+ class EventBus
6
6
  HOOK_TIMEOUT_SECONDS = 2
7
7
 
8
8
  class HookTimeout < StandardError; end
9
9
 
10
10
  EVENTS = {
11
- # RPC events — gem-internal, keep :store
12
- resolve_intake: { mode: :rpc, args: %i[store config args] },
13
- transform_rows: { mode: :rpc, args: %i[store rows config] },
14
- validate: { mode: :rpc, args: %i[store] },
15
-
16
- # Pubsub events — ship :ctx (Hooks::Context) instead of raw store
17
- entry_put: { mode: :pubsub, args: %i[ctx key envelope] },
18
- entry_deleted: { mode: :pubsub, args: %i[ctx key] },
19
- entry_refreshed: { mode: :pubsub, args: %i[ctx key envelope change] },
20
- entry_renamed: { mode: :pubsub, args: %i[ctx key from_key to_key envelope] },
21
- build_completed: { mode: :pubsub, args: %i[ctx key envelope sources] },
22
- proposal_accepted: { mode: :pubsub, args: %i[ctx key target_key] },
23
- proposal_rejected: { mode: :pubsub, args: %i[ctx key target_key] },
24
- file_published: { mode: :pubsub, args: %i[ctx key envelope source target] },
25
- store_loaded: { mode: :pubsub, args: %i[ctx] },
26
- refresh_started: { mode: :pubsub, args: %i[ctx key mode] },
27
- refresh_failed: { mode: :pubsub, args: %i[ctx key error_class error_message] },
28
- refresh_backgrounded: { mode: :pubsub, args: %i[ctx key started_at budget_ms] },
11
+ entry_put: %i[ctx key envelope],
12
+ entry_deleted: %i[ctx key],
13
+ entry_refreshed: %i[ctx key envelope change],
14
+ entry_renamed: %i[ctx key from_key to_key envelope],
15
+ build_completed: %i[ctx key envelope sources],
16
+ proposal_accepted: %i[ctx key target_key],
17
+ proposal_rejected: %i[ctx key target_key],
18
+ file_published: %i[ctx key envelope source target],
19
+ store_loaded: %i[ctx],
20
+ refresh_started: %i[ctx key mode],
21
+ refresh_failed: %i[ctx key error_class error_message],
22
+ refresh_backgrounded: %i[ctx key started_at budget_ms],
29
23
  }.freeze
30
24
 
31
- def initialize
32
- @rpc = Hash.new { |h, k| h[k] = {} }
25
+ RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
26
+
27
+ def initialize(error_log: ErrorLog.new)
33
28
  @pubsub = Hash.new { |h, k| h[k] = [] }
34
29
  @error_handlers = []
30
+ @error_log = error_log
35
31
  end
36
32
 
33
+ attr_reader :error_log
34
+
37
35
  def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
38
36
 
39
37
  def register(event, name, keys: nil, &blk)
40
38
  event_sym = event.to_sym
41
- spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
42
- shape_check!(event_sym, spec, blk)
43
- name = name.to_sym
44
-
45
- case spec[:mode]
46
- when :rpc
47
- raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
39
+ raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
48
40
 
49
- @rpc[event_sym][name] = blk
50
- when :pubsub
51
- raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
41
+ required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
42
+ shape_check!(event_sym, required, blk)
43
+ name = name.to_sym
44
+ raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
52
45
 
53
- @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
54
- end
46
+ @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
55
47
  end
56
48
 
57
49
  def on_error(&block) = @error_handlers << block
58
50
 
59
- def rpc_callable(event, name)
60
- @rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
61
- end
51
+ def listeners(event, key:) = @pubsub[event.to_sym].select { |h| match?(h[:keys], key) }
62
52
 
63
- def rpc_names(event) = @rpc[event.to_sym].keys
64
- def pubsub_handlers(event) = @pubsub[event.to_sym]
65
- def listeners(event, key:) = @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
53
+ def pubsub_handlers(event) = @pubsub[event.to_sym]
66
54
 
67
55
  def publish(event, strict: false, **kwargs)
68
56
  key = kwargs[:key] || "-"
@@ -93,29 +81,33 @@ module Textus
93
81
  def invoke(event, sub, key, kwargs)
94
82
  accepted = filter_kwargs(sub[:callable], kwargs)
95
83
  error = nil
96
-
97
84
  thread = Thread.new do
98
85
  sub[:callable].call(**accepted)
99
86
  rescue StandardError => e
100
87
  error = e
101
88
  end
102
-
103
89
  if thread.join(HOOK_TIMEOUT_SECONDS).nil?
104
90
  thread.kill
105
91
  err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
106
92
  notify_error(event, sub, key, kwargs, err)
107
93
  return [:timed_out, err]
108
94
  end
109
-
110
95
  if error
111
96
  notify_error(event, sub, key, kwargs, error)
112
97
  return [:errored, error]
113
98
  end
114
-
115
99
  [:ok, nil]
116
100
  end
117
101
 
118
102
  def notify_error(event, sub, key, kwargs, error)
103
+ @error_log.record(
104
+ seq: kwargs[:_audit_seq] || -1,
105
+ event: event,
106
+ hook: sub[:name],
107
+ key: key,
108
+ error_class: error.class.name,
109
+ error_message: error.message,
110
+ )
119
111
  @error_handlers.each do |handler|
120
112
  handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
121
113
  rescue StandardError => e
@@ -127,18 +119,16 @@ module Textus
127
119
  params = callable.parameters
128
120
  return kwargs if params.any? { |type, _| type == :keyrest }
129
121
 
130
- accepted = params.each_with_object([]) do |(type, name), acc|
131
- acc << name if %i[key keyreq].include?(type)
132
- end
122
+ accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
133
123
  kwargs.slice(*accepted)
134
124
  end
135
125
 
136
- def shape_check!(event, spec, blk)
137
- required = spec[:args]
126
+ def shape_check!(event, required, blk)
138
127
  provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
139
- keyrest = provided.any? { |t, _| t == :keyrest }
140
- missing = required - provided.map { |_, n| n }
141
- return if keyrest || missing.empty?
128
+ return if provided.any? { |t, _| t == :keyrest }
129
+
130
+ missing = required - provided.map { |_, n| n }
131
+ return if missing.empty?
142
132
 
143
133
  raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
144
134
  end
@@ -148,8 +138,6 @@ module Textus
148
138
 
149
139
  Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
150
140
  end
151
-
152
- def matches_any?(globs, key) = match?(globs, key)
153
141
  end
154
142
  end
155
143
  end
@@ -1,8 +1,34 @@
1
1
  module Textus
2
2
  module Hooks
3
3
  class Loader
4
- def initialize(bus:)
5
- @bus = bus
4
+ # A small DSL object passed to user hook blocks. Routes `.on(...)` to the
5
+ # EventBus and `.rpc(...)` / `.register(...)` to the RpcRegistry.
6
+ class Dsl
7
+ def initialize(events:, rpc:)
8
+ @events = events
9
+ @rpc = rpc
10
+ end
11
+
12
+ # Pubsub registration — delegates to EventBus.
13
+ # Also handles RPC event names by delegating to RpcRegistry.
14
+ def on(event, name, keys: nil, &)
15
+ if Hooks::RpcRegistry::EVENTS.key?(event.to_sym)
16
+ @rpc.register(event, name, &)
17
+ else
18
+ @events.register(event, name, keys: keys, &)
19
+ end
20
+ end
21
+
22
+ # Explicit RPC registration.
23
+ def register(event, name, &)
24
+ @rpc.register(event, name, &)
25
+ end
26
+ end
27
+
28
+ def initialize(events:, rpc:)
29
+ @events = events
30
+ @rpc = rpc
31
+ @dsl = Dsl.new(events: @events, rpc: @rpc)
6
32
  end
7
33
 
8
34
  def load_dir(dir)
@@ -18,7 +44,7 @@ module Textus
18
44
  end
19
45
 
20
46
  Textus.drain_hook_blocks.each do |blk|
21
- blk.call(@bus)
47
+ blk.call(@dsl)
22
48
  rescue StandardError, ScriptError => e
23
49
  raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
24
50
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ class RpcRegistry
6
+ EVENTS = {
7
+ resolve_intake: %i[caps config args],
8
+ transform_rows: %i[caps rows config],
9
+ validate: %i[caps],
10
+ }.freeze
11
+
12
+ PUBSUB_EVENTS = EventBus::EVENTS.keys.freeze
13
+
14
+ def initialize
15
+ @table = Hash.new { |h, k| h[k] = {} }
16
+ end
17
+
18
+ def register(event, name, &blk)
19
+ event_sym = event.to_sym
20
+ raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
21
+
22
+ required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
23
+ shape_check!(event_sym, required, blk)
24
+ name = name.to_sym
25
+ raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
26
+
27
+ @table[event_sym][name] = blk
28
+ end
29
+
30
+ def names(event) = @table[event.to_sym].keys
31
+
32
+ def callable(event, name)
33
+ @table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
34
+ end
35
+
36
+ # Invoke a registered callable, injecting `caps:` under the kwarg name
37
+ # the callable declares. Legacy `store:` is rejected (no shim).
38
+ def invoke(event, name, caps:, **other)
39
+ blk = callable(event, name)
40
+ params = blk.parameters
41
+ accepts_keyrest = params.any? { |t, _| t == :keyrest }
42
+ declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
43
+
44
+ if declared.include?(:store)
45
+ raise UsageError.new(
46
+ "RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
47
+ "(Textus::Application::ReadCaps / WriteCaps)",
48
+ )
49
+ end
50
+
51
+ kwargs = other.dup
52
+ kwargs[:caps] = caps if accepts_keyrest || declared.include?(:caps)
53
+ blk.call(**kwargs)
54
+ end
55
+
56
+ private
57
+
58
+ def shape_check!(event, required, blk)
59
+ provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
60
+ return if provided.any? { |t, _| t == :keyrest }
61
+
62
+ param_names = provided.map { |_, n| n }
63
+ # Allow `store:` as a stand-in for `caps:` so registration succeeds;
64
+ # invoke will raise UsageError when the callable is actually called.
65
+ effective_required = if param_names.include?(:store)
66
+ required.map { |r| r == :caps ? :store : r }
67
+ else
68
+ required
69
+ end
70
+ missing = effective_required - param_names
71
+ return if missing.empty?
72
+
73
+ raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -3,18 +3,17 @@
3
3
  module Textus
4
4
  module Infra
5
5
  # Writes an "event_error" audit row when a user hook raises during
6
- # Hooks::Bus publish. Attached at Store boot.
6
+ # Hooks::EventBus publish. Attached at Store boot.
7
7
  #
8
- # Integration: uses Hooks::Bus#on_error callback (chosen over a
8
+ # Integration: uses Hooks::EventBus#on_error callback (chosen over a
9
9
  # synthetic :hook_error event because the bus already owns the
10
10
  # rescue and the failure is a bus-internal concern, not a domain
11
11
  # event subscribers should be able to filter by key glob).
12
12
  #
13
- # NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
14
- # "rename" are still written directly by Store::Writer and
15
- # Application::Writes::Mv. Moving those into this subscriber requires
16
- # event payloads to carry etag_before/etag_after across many write paths;
17
- # that is properly a 0.18 port-extraction concern.
13
+ # Lifecycle audit rows for verb: "put" / "delete" / "rename" are written
14
+ # by Application::Envelope::Writer directly (it owns the
15
+ # audit-append-as-final-step invariant); this subscriber covers the
16
+ # hook-failure case the writer never sees.
18
17
  class AuditSubscriber
19
18
  def initialize(audit_log)
20
19
  @audit_log = audit_log
@@ -21,7 +21,7 @@ module Textus
21
21
 
22
22
  begin
23
23
  store = Textus::Store.new(store_root)
24
- Textus::Operations.for(store, role: "runner").refresh(key)
24
+ store.session(role: "runner").refresh(key)
25
25
  rescue StandardError
26
26
  # Already logged via :refresh_failed; exit cleanly.
27
27
  ensure
@@ -4,12 +4,16 @@ module Textus
4
4
  # Returns the absolute filesystem path for a manifest entry (the leaf file,
5
5
  # not a nested directory). Adds the format's primary extension when the
6
6
  # manifest entry's `path:` is extensionless.
7
- def self.resolve(manifest, mentry)
7
+ #
8
+ # The first argument is a Manifest::Data (or anything responding to .root);
9
+ # callers historically passed the whole Manifest but should now pass
10
+ # `manifest.data`.
11
+ def self.resolve(data, mentry)
8
12
  primary_ext = Entry.for_format(mentry.format).extensions.first
9
13
  if File.extname(mentry.path) == ""
10
- File.join(manifest.root, "zones", mentry.path + primary_ext)
14
+ File.join(data.root, "zones", mentry.path + primary_ext)
11
15
  else
12
- File.join(manifest.root, "zones", mentry.path)
16
+ File.join(data.root, "zones", mentry.path)
13
17
  end
14
18
  end
15
19
  end
@@ -0,0 +1,78 @@
1
+ require_relative "schema"
2
+ require_relative "role_kinds"
3
+
4
+ module Textus
5
+ class Manifest
6
+ # Immutable, parsed view of a manifest YAML document.
7
+ #
8
+ # Holds raw structural data (zones, entries, audit_config, role_mapping)
9
+ # but no behaviour beyond accessors. Behaviour (zone authority, key
10
+ # resolution, rules) lives on Manifest::Policy / Resolver / Rules.
11
+ class Data
12
+ AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
+
14
+ attr_reader :raw, :root, :entries, :zones, :zone_readers, :audit_config, :role_mapping, :policy
15
+
16
+ def self.validate_key!(key)
17
+ raise UsageError.new("empty key") if key.nil? || key.empty?
18
+
19
+ Key::Grammar.validate!(key)
20
+ end
21
+
22
+ # Forwarder used by Resolver and Entry classes that received a Data
23
+ # but were written against the historical Manifest API.
24
+ def validate_key!(key) = self.class.validate_key!(key)
25
+
26
+ def self.parse(raw, root:)
27
+ raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
28
+
29
+ Schema.validate!(raw)
30
+ new(raw: raw, root: root)
31
+ end
32
+
33
+ def initialize(raw:, root:)
34
+ @raw = raw
35
+ @root = root
36
+ @zones = Array(raw["zones"]).to_h { |z| [z["name"], Array(z["write_policy"])] }
37
+ @zone_readers = Array(raw["zones"]).to_h do |z|
38
+ rp = z["read_policy"]
39
+ [z["name"], rp.nil? ? :all : Array(rp)]
40
+ end
41
+ @audit_config = build_audit_config(raw)
42
+ @role_mapping = RoleKinds.resolve(raw["roles"])
43
+ # Policy is constructed before entries because Entry validators
44
+ # call `entry.in_generator_zone?` which routes through Policy.
45
+ @policy = Policy.new(self)
46
+ @entries = build_entries(raw)
47
+ validate_declared_keys!
48
+ freeze
49
+ end
50
+
51
+ private
52
+
53
+ def build_audit_config(raw)
54
+ a = raw["audit"] || {}
55
+ {
56
+ max_size: a["max_size"] || AUDIT_DEFAULTS[:max_size],
57
+ keep: a["keep"] || AUDIT_DEFAULTS[:keep],
58
+ }.freeze
59
+ end
60
+
61
+ def build_entries(raw)
62
+ Array(raw["entries"]).map do |e|
63
+ entry = Manifest::Entry::Parser.call(self, e)
64
+ Manifest::Entry::Validators.run_all(entry)
65
+ entry
66
+ end.freeze
67
+ end
68
+
69
+ def validate_declared_keys!
70
+ @entries.each do |e|
71
+ raise UsageError.new("empty key") if e.key.nil? || e.key.empty?
72
+
73
+ Key::Grammar.validate!(e.key)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -19,13 +19,13 @@ module Textus
19
19
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
20
20
 
21
21
  def zone_writers
22
- @manifest.zone_writers(@zone)
22
+ @manifest.policy.zone_writers(@zone)
23
23
  rescue UsageError => e
24
24
  raise UsageError.new("entry '#{@key}': #{e.message}")
25
25
  end
26
26
 
27
- def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
28
- def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
27
+ def in_generator_zone? = @manifest.policy.zone_kinds(@zone).include?(:generator)
28
+ def in_proposal_zone? = @manifest.policy.zone_kinds(@zone).include?(:proposer)
29
29
 
30
30
  def nested? = false
31
31
  def derived? = false
@@ -42,7 +42,7 @@ module Textus
42
42
  def index_filename = nil
43
43
 
44
44
  PublishContext = Struct.new(
45
- :repo_root, :manifest, :file_store, :root, :store, :ctx, :bus, :hook_context,
45
+ :repo_root, :manifest, :file_store, :root, :caps, :rpc, :session, :ctx, :bus, :hook_context,
46
46
  :reader, :emit, # callables: reader.call(key) → envelope; emit.call(event, **payload)
47
47
  keyword_init: true
48
48
  )
@@ -2,8 +2,8 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Derived < Base
5
- Projection = Data.define(:select, :pluck, :sort_by, :transform)
6
- External = Data.define(:sources, :runner)
5
+ Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
6
+ External = ::Data.define(:sources, :runner)
7
7
 
8
8
  attr_reader :source, :template, :inject_boot, :events
9
9
 
@@ -22,9 +22,8 @@ module Textus
22
22
  def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
23
23
  return nil unless in_generator_zone?
24
24
 
25
- target_path = Textus::Application::Writes::Materializer.new(
26
- ctx: pctx.ctx, manifest: pctx.manifest, file_store: pctx.file_store,
27
- bus: pctx.bus, root: pctx.root, store: pctx.store
25
+ target_path = Textus::Application::Write::Materializer.new(
26
+ ctx: pctx.ctx, caps: pctx.caps, rpc: pctx.rpc, session: pctx.session,
28
27
  ).run(self)
29
28
 
30
29
  envelope = pctx.reader.call(@key)
@@ -4,7 +4,7 @@ module Textus
4
4
  module Validators
5
5
  module Events
6
6
  def self.call(entry)
7
- pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
7
+ pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
8
8
  events = entry.events
9
9
  events.each_key do |evt|
10
10
  next if pubsub_events.include?(evt.to_sym)