textus 0.22.0 → 0.29.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +195 -48
  3. data/CHANGELOG.md +178 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +79 -42
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/boot.rb +31 -29
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/group/mcp.rb +9 -0
  11. data/lib/textus/cli/group/zone.rb +9 -0
  12. data/lib/textus/cli/verb/accept.rb +1 -1
  13. data/lib/textus/cli/verb/audit.rb +2 -2
  14. data/lib/textus/cli/verb/blame.rb +1 -1
  15. data/lib/textus/cli/verb/boot.rb +1 -1
  16. data/lib/textus/cli/verb/build.rb +3 -3
  17. data/lib/textus/cli/verb/delete.rb +1 -1
  18. data/lib/textus/cli/verb/deps.rb +1 -1
  19. data/lib/textus/cli/verb/doctor.rb +1 -1
  20. data/lib/textus/cli/verb/freshness.rb +1 -1
  21. data/lib/textus/cli/verb/get.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -4
  23. data/lib/textus/cli/verb/hooks.rb +11 -14
  24. data/lib/textus/cli/verb/key_delete.rb +24 -0
  25. data/lib/textus/cli/verb/list.rb +1 -1
  26. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  27. data/lib/textus/cli/verb/migrate.rb +18 -0
  28. data/lib/textus/cli/verb/mv.rb +11 -3
  29. data/lib/textus/cli/verb/published.rb +1 -1
  30. data/lib/textus/cli/verb/pulse.rb +1 -1
  31. data/lib/textus/cli/verb/put.rb +8 -6
  32. data/lib/textus/cli/verb/rdeps.rb +1 -1
  33. data/lib/textus/cli/verb/refresh.rb +1 -1
  34. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  35. data/lib/textus/cli/verb/reject.rb +1 -1
  36. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  37. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  38. data/lib/textus/cli/verb/schema.rb +1 -1
  39. data/lib/textus/cli/verb/uid.rb +1 -1
  40. data/lib/textus/cli/verb/where.rb +1 -1
  41. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  42. data/lib/textus/cli/verb.rb +7 -7
  43. data/lib/textus/cli.rb +0 -7
  44. data/lib/textus/container.rb +23 -0
  45. data/lib/textus/dispatcher.rb +49 -0
  46. data/lib/textus/doctor/check/audit_log.rb +2 -2
  47. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  48. data/lib/textus/doctor/check/hooks.rb +4 -3
  49. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  50. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  51. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  52. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  53. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  54. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  55. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  56. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  57. data/lib/textus/doctor/check/schemas.rb +2 -2
  58. data/lib/textus/doctor/check/sentinels.rb +11 -9
  59. data/lib/textus/doctor/check/templates.rb +2 -2
  60. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  61. data/lib/textus/doctor/check.rb +12 -3
  62. data/lib/textus/doctor.rb +24 -27
  63. data/lib/textus/domain/authorizer.rb +6 -6
  64. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  65. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  66. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  67. data/lib/textus/domain/sentinel.rb +9 -65
  68. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  69. data/lib/textus/domain/staleness/intake_check.rb +20 -12
  70. data/lib/textus/domain/staleness.rb +4 -4
  71. data/lib/textus/envelope/io/reader.rb +44 -0
  72. data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
  73. data/lib/textus/hooks/builtin.rb +14 -14
  74. data/lib/textus/hooks/context.rb +30 -13
  75. data/lib/textus/hooks/error_log.rb +32 -0
  76. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  77. data/lib/textus/hooks/loader.rb +29 -3
  78. data/lib/textus/hooks/rpc_registry.rb +77 -0
  79. data/lib/textus/key/path.rb +7 -3
  80. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  81. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  82. data/lib/textus/maintenance/migrate.rb +51 -0
  83. data/lib/textus/maintenance/rule_lint.rb +56 -0
  84. data/lib/textus/maintenance/zone_mv.rb +51 -0
  85. data/lib/textus/maintenance.rb +15 -0
  86. data/lib/textus/manifest/data.rb +79 -0
  87. data/lib/textus/manifest/entry/base.rb +38 -18
  88. data/lib/textus/manifest/entry/derived.rb +8 -9
  89. data/lib/textus/manifest/entry/nested.rb +7 -9
  90. data/lib/textus/manifest/entry/parser.rb +2 -2
  91. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  94. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  96. data/lib/textus/manifest/entry/validators.rb +2 -2
  97. data/lib/textus/manifest/entry.rb +0 -5
  98. data/lib/textus/manifest/policy.rb +48 -0
  99. data/lib/textus/manifest/resolver.rb +14 -14
  100. data/lib/textus/manifest/rules.rb +1 -1
  101. data/lib/textus/manifest.rb +47 -110
  102. data/lib/textus/mcp/errors.rb +32 -0
  103. data/lib/textus/mcp/server.rb +126 -0
  104. data/lib/textus/mcp/session.rb +40 -0
  105. data/lib/textus/mcp/tool_schemas.rb +71 -0
  106. data/lib/textus/mcp/tools.rb +129 -0
  107. data/lib/textus/mcp.rb +6 -0
  108. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  109. data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
  110. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  111. data/lib/textus/{infra → ports}/clock.rb +1 -1
  112. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  113. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  114. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  115. data/lib/textus/ports/sentinel_store.rb +67 -0
  116. data/lib/textus/ports/storage/file_stat.rb +19 -0
  117. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  118. data/lib/textus/projection.rb +91 -0
  119. data/lib/textus/read/audit.rb +111 -0
  120. data/lib/textus/read/blame.rb +81 -0
  121. data/lib/textus/read/boot.rb +18 -0
  122. data/lib/textus/read/deps.rb +24 -0
  123. data/lib/textus/read/doctor.rb +19 -0
  124. data/lib/textus/read/freshness.rb +101 -0
  125. data/lib/textus/read/get.rb +66 -0
  126. data/lib/textus/read/get_or_refresh.rb +69 -0
  127. data/lib/textus/read/list.rb +15 -0
  128. data/lib/textus/read/policy_explain.rb +37 -0
  129. data/lib/textus/read/published.rb +15 -0
  130. data/lib/textus/read/pulse.rb +89 -0
  131. data/lib/textus/read/rdeps.rb +25 -0
  132. data/lib/textus/read/schema_envelope.rb +16 -0
  133. data/lib/textus/read/stale.rb +17 -0
  134. data/lib/textus/read/uid.rb +20 -0
  135. data/lib/textus/read/validate_all.rb +22 -0
  136. data/lib/textus/read/validator.rb +84 -0
  137. data/lib/textus/read/where.rb +16 -0
  138. data/lib/textus/role_scope.rb +49 -0
  139. data/lib/textus/schema/tools.rb +14 -10
  140. data/lib/textus/store.rb +25 -11
  141. data/lib/textus/version.rb +1 -1
  142. data/lib/textus/write/accept.rb +86 -0
  143. data/lib/textus/write/authority_gate.rb +24 -0
  144. data/lib/textus/write/delete.rb +54 -0
  145. data/lib/textus/write/materializer.rb +48 -0
  146. data/lib/textus/write/mv.rb +123 -0
  147. data/lib/textus/write/publish.rb +66 -0
  148. data/lib/textus/write/put.rb +59 -0
  149. data/lib/textus/write/refresh_all.rb +44 -0
  150. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  151. data/lib/textus/write/refresh_worker.rb +138 -0
  152. data/lib/textus/write/reject.rb +54 -0
  153. data/lib/textus.rb +7 -1
  154. metadata +75 -46
  155. data/lib/textus/application/context.rb +0 -34
  156. data/lib/textus/application/projection.rb +0 -91
  157. data/lib/textus/application/reads/audit.rb +0 -94
  158. data/lib/textus/application/reads/blame.rb +0 -82
  159. data/lib/textus/application/reads/deps.rb +0 -26
  160. data/lib/textus/application/reads/freshness.rb +0 -88
  161. data/lib/textus/application/reads/get.rb +0 -67
  162. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  163. data/lib/textus/application/reads/list.rb +0 -17
  164. data/lib/textus/application/reads/policy_explain.rb +0 -39
  165. data/lib/textus/application/reads/published.rb +0 -17
  166. data/lib/textus/application/reads/pulse.rb +0 -63
  167. data/lib/textus/application/reads/rdeps.rb +0 -27
  168. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  169. data/lib/textus/application/reads/stale.rb +0 -15
  170. data/lib/textus/application/reads/uid.rb +0 -23
  171. data/lib/textus/application/reads/validate_all.rb +0 -24
  172. data/lib/textus/application/reads/validator.rb +0 -86
  173. data/lib/textus/application/reads/where.rb +0 -18
  174. data/lib/textus/application/refresh/all.rb +0 -52
  175. data/lib/textus/application/refresh/orchestrator.rb +0 -78
  176. data/lib/textus/application/refresh/worker.rb +0 -116
  177. data/lib/textus/application/writes/accept.rb +0 -89
  178. data/lib/textus/application/writes/authority_gate.rb +0 -26
  179. data/lib/textus/application/writes/delete.rb +0 -33
  180. data/lib/textus/application/writes/materializer.rb +0 -50
  181. data/lib/textus/application/writes/mv.rb +0 -105
  182. data/lib/textus/application/writes/publish.rb +0 -81
  183. data/lib/textus/application/writes/put.rb +0 -37
  184. data/lib/textus/application/writes/reject.rb +0 -50
  185. data/lib/textus/infra/event_bus.rb +0 -27
  186. data/lib/textus/operations.rb +0 -176
@@ -1,52 +0,0 @@
1
- module Textus
2
- module Application
3
- module Refresh
4
- class All
5
- def initialize(ctx:, manifest:, envelope_io:, bus:, store:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
- @ctx = ctx
7
- @manifest = manifest
8
- @envelope_io = envelope_io
9
- @bus = bus
10
- @store = store
11
- @authorizer = authorizer
12
- @hook_context = hook_context
13
- end
14
-
15
- def call(prefix: nil, zone: nil)
16
- worker = Textus::Application::Refresh::Worker.new(
17
- ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io, bus: @bus,
18
- store: @store, authorizer: @authorizer, hook_context: @hook_context
19
- )
20
-
21
- stale_rows = Textus::Application::Reads::Stale.new(manifest: @manifest).call(prefix: prefix, zone: zone)
22
- refreshed = []
23
- failed = []
24
- skipped = []
25
-
26
- stale_rows.each do |row|
27
- key = row["key"] || row[:key]
28
- reason = row["reason"] || row[:reason]
29
- if reason.to_s.match?(/ttl exceeded|never refreshed/)
30
- begin
31
- worker.run(key)
32
- refreshed << key
33
- rescue Textus::Error => e
34
- failed << { "key" => key, "error" => e.message }
35
- end
36
- else
37
- skipped << { "key" => key, "reason" => reason }
38
- end
39
- end
40
-
41
- {
42
- "protocol" => Textus::PROTOCOL,
43
- "ok" => failed.empty?,
44
- "refreshed" => refreshed,
45
- "failed" => failed,
46
- "skipped" => skipped,
47
- }
48
- end
49
- end
50
- end
51
- end
52
- end
@@ -1,78 +0,0 @@
1
- module Textus
2
- module Application
3
- module Refresh
4
- class Orchestrator
5
- def initialize(worker:, store_root:, bus: nil, store: nil, ctx: nil, hook_context: nil, detached_spawner: nil) # rubocop:disable Metrics/ParameterLists
6
- @worker = worker
7
- @store_root = store_root
8
- @bus = bus
9
- @store = store
10
- @ctx = ctx
11
- @hook_context = hook_context
12
- @detached_spawner = detached_spawner || default_spawner
13
- end
14
-
15
- def execute(action, key:)
16
- case action
17
- when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
18
- when Textus::Domain::Action::RefreshSync then run_sync(key)
19
- when Textus::Domain::Action::RefreshTimed then run_timed(action.budget_ms, key)
20
- else raise ArgumentError.new("unknown action: #{action.inspect}")
21
- end
22
- end
23
-
24
- private
25
-
26
- def run_sync(key)
27
- envelope = @worker.run(key)
28
- Textus::Domain::Outcome::Refreshed.new(envelope: envelope)
29
- rescue Textus::Error => e
30
- Textus::Domain::Outcome::Failed.new(error: e)
31
- end
32
-
33
- def run_timed(budget_ms, key)
34
- unless Textus::Infra::Refresh::Detached.supported?
35
- return Textus::Domain::Outcome::Failed.new(
36
- error: Textus::UsageError.new("timed_sync requires fork (Unix only)"),
37
- )
38
- end
39
-
40
- result = nil
41
- thread = Thread.new do
42
- result = @worker.run(key)
43
- rescue Textus::Error => e
44
- result = e
45
- end
46
-
47
- thread.join(budget_ms / 1000.0)
48
-
49
- if thread.alive?
50
- thread.kill
51
-
52
- # Single-flight: if a sibling process / earlier fork holds the
53
- # per-leaf lock, don't fork another worker — they're already
54
- # doing this work.
55
- probe = Textus::Infra::Refresh::Lock.new(root: @store_root, key: key)
56
- return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
57
-
58
- probe.release
59
-
60
- payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
61
- payload[:ctx] = @hook_context if @hook_context
62
- @bus&.publish(:refresh_backgrounded, **payload)
63
- @detached_spawner.call(store_root: @store_root, key: key)
64
- Textus::Domain::Outcome::Detached.new
65
- elsif result.is_a?(Textus::Error)
66
- Textus::Domain::Outcome::Failed.new(error: result)
67
- else
68
- Textus::Domain::Outcome::Refreshed.new(envelope: result)
69
- end
70
- end
71
-
72
- def default_spawner
73
- Textus::Infra::Refresh::Detached.method(:spawn)
74
- end
75
- end
76
- end
77
- end
78
- end
@@ -1,116 +0,0 @@
1
- require "timeout"
2
-
3
- module Textus
4
- module Application
5
- module Refresh
6
- class Worker
7
- FETCH_TIMEOUT_SECONDS = 30
8
-
9
- def initialize(ctx:, manifest:, envelope_io:, bus:, store:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
10
- @ctx = ctx
11
- @manifest = manifest
12
- @envelope_io = envelope_io
13
- @bus = bus
14
- @store = store
15
- @authorizer = authorizer
16
- @hook_context = hook_context
17
- end
18
-
19
- def run(key)
20
- res = @manifest.resolver.resolve(key)
21
- mentry = res.entry
22
- path = res.path
23
- remaining = res.remaining
24
- raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
25
-
26
- before_etag = File.exist?(path) ? Etag.for_file(path) : nil
27
- result = fetch_with_bus(key, mentry, remaining)
28
- persist_and_notify(key, mentry, result, before_etag)
29
- end
30
-
31
- private
32
-
33
- def fetch_timeout_for(key)
34
- rule = @manifest.rules_for(key)
35
- rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
36
- end
37
-
38
- def fetch_with_bus(key, mentry, remaining)
39
- callable = @bus.rpc_callable(:resolve_intake, mentry.handler)
40
- @bus.publish(:refresh_started, ctx: @hook_context, key: key, mode: :sync)
41
- call_intake(key, mentry, callable, remaining)
42
- end
43
-
44
- def call_intake(key, mentry, callable, remaining)
45
- timeout = fetch_timeout_for(key)
46
- Timeout.timeout(timeout) do
47
- callable.call(
48
- store: @store,
49
- config: mentry.config,
50
- args: { trigger_key: key, leaf_segments: remaining || [] },
51
- )
52
- end
53
- rescue Timeout::Error
54
- @bus.publish(:refresh_failed, ctx: @hook_context, key: key,
55
- error_class: "Timeout::Error",
56
- error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
57
- raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
58
- rescue Textus::Error => e
59
- @bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
60
- error_message: e.message)
61
- raise
62
- rescue StandardError => e
63
- @bus.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
64
- error_message: e.message)
65
- raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
66
- end
67
-
68
- def persist_and_notify(key, mentry, result, before_etag)
69
- normalized = self.class.send(:normalize_action_result, result, format: mentry.format)
70
- @authorizer.authorize_write!(mentry, role: @ctx.role)
71
- envelope = @envelope_io.write(
72
- key,
73
- mentry: mentry,
74
- payload: Textus::Application::Writes::EnvelopeIO::Payload.new(
75
- meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
76
- ),
77
- )
78
- change = detect_change(before_etag, envelope)
79
- @bus.publish(:entry_refreshed, ctx: @hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
80
- envelope
81
- end
82
-
83
- def detect_change(before_etag, envelope)
84
- if before_etag.nil? then :created
85
- elsif envelope.etag == before_etag then :unchanged
86
- else :updated
87
- end
88
- end
89
-
90
- def self.normalize_action_result(res, format:)
91
- res = res.transform_keys(&:to_s) if res.is_a?(Hash)
92
- res ||= {}
93
- meta_val = res["_meta"]
94
- body = res["body"]
95
- content = res["content"]
96
-
97
- case format
98
- when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
99
- when "text" then { meta: {}, body: body.to_s, content: nil }
100
- when "json", "yaml"
101
- if !content.nil?
102
- { meta: meta_val || {}, body: nil, content: content }
103
- elsif !body.nil?
104
- { meta: {}, body: body.to_s, content: nil }
105
- else
106
- raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
107
- end
108
- else
109
- raise Textus::UsageError.new("unknown format #{format.inspect}")
110
- end
111
- end
112
- private_class_method :normalize_action_result
113
- end
114
- end
115
- end
116
- end
@@ -1,89 +0,0 @@
1
- require_relative "authority_gate"
2
-
3
- module Textus
4
- module Application
5
- module Writes
6
- class Accept
7
- include AuthorityGate
8
-
9
- def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
10
- @ctx = ctx
11
- @manifest = manifest
12
- @file_store = file_store
13
- @schemas = schemas
14
- @envelope_io = envelope_io
15
- @bus = bus
16
- @authorizer = authorizer
17
- @hook_context = hook_context
18
- end
19
-
20
- def call(pending_key)
21
- assert_accept_authority!("accept")
22
-
23
- env = Textus::Application::Reads::Get.new(
24
- ctx: @ctx, manifest: @manifest, file_store: @file_store,
25
- ).call(pending_key)
26
- proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
27
- target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
28
- action = proposal["action"] || "put"
29
-
30
- evaluate_promotion!(env, target)
31
-
32
- case action
33
- when "put"
34
- # Nested proposal "frontmatter" — the meta to write to the accepted
35
- # target. Not related to the removed intake-handler legacy bridge.
36
- target_meta = env.meta["frontmatter"] || {}
37
- target_body = env.body
38
- put_op.call(target, meta: target_meta, body: target_body)
39
- when "delete"
40
- delete_op.call(target)
41
- else
42
- raise ProposalError.new("unknown action: #{action}")
43
- end
44
-
45
- delete_op.call(pending_key)
46
-
47
- @bus.publish(:proposal_accepted,
48
- ctx: @hook_context,
49
- key: pending_key,
50
- target_key: target)
51
-
52
- { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
53
- end
54
-
55
- private
56
-
57
- def put_op
58
- @put_op ||= Textus::Application::Writes::Put.new(
59
- ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
60
- bus: @bus, authorizer: @authorizer, hook_context: @hook_context
61
- )
62
- end
63
-
64
- def delete_op
65
- @delete_op ||= Textus::Application::Writes::Delete.new(
66
- ctx: @ctx, manifest: @manifest, envelope_io: @envelope_io,
67
- bus: @bus, authorizer: @authorizer, hook_context: @hook_context
68
- )
69
- end
70
-
71
- def evaluate_promotion!(env, target_key)
72
- rules = @manifest.rules_for(target_key)
73
- promote = rules.promote
74
- return if promote.nil? || promote.requires.empty?
75
-
76
- policy = Textus::Application::Policy::Promotion.from_names(promote.requires)
77
- result = policy.evaluate(
78
- entry: env, schemas: @schemas, manifest: @manifest, role: @ctx.role,
79
- )
80
- return if result.ok?
81
-
82
- raise ProposalError.new(
83
- "promotion gate failed: #{result.reasons.join("; ")}",
84
- )
85
- end
86
- end
87
- end
88
- end
89
- end
@@ -1,26 +0,0 @@
1
- module Textus
2
- module Application
3
- module Writes
4
- # Shared gate for write verbs that require the caller to hold the
5
- # manifest's accept_authority role. Provides one method, expressed
6
- # as two early-returns rather than a ternary, so each failure mode
7
- # reads on its own line.
8
- module AuthorityGate
9
- def assert_accept_authority!(verb)
10
- return if @manifest.role_kind(@ctx.role) == :accept_authority
11
-
12
- authority = @manifest.roles_with_kind(:accept_authority).first
13
- if authority.nil?
14
- raise ProposalError.new(
15
- "no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
16
- )
17
- end
18
-
19
- raise ProposalError.new(
20
- "only #{authority} role can #{verb} proposals; got '#{@ctx.role}'",
21
- )
22
- end
23
- end
24
- end
25
- end
26
- end
@@ -1,33 +0,0 @@
1
- module Textus
2
- module Application
3
- module Writes
4
- class Delete
5
- def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
6
- @ctx = ctx
7
- @manifest = manifest
8
- @envelope_io = envelope_io
9
- @bus = bus
10
- @authorizer = authorizer
11
- @hook_context = hook_context
12
- end
13
-
14
- def call(key, if_etag: nil, suppress_events: false)
15
- @manifest.validate_key!(key)
16
- mentry = @manifest.resolver.resolve(key).entry
17
-
18
- @authorizer.authorize_write!(mentry, role: @ctx.role)
19
-
20
- @envelope_io.delete(key, mentry: mentry, if_etag: if_etag)
21
-
22
- unless suppress_events
23
- @bus.publish(:entry_deleted,
24
- ctx: @hook_context,
25
- key: key)
26
- end
27
-
28
- { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
29
- end
30
- end
31
- end
32
- end
33
- end
@@ -1,50 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Application
5
- module Writes
6
- # Materializes a single Derived manifest entry onto disk by running
7
- # the builder pipeline (template + projection + external runner).
8
- # Extracted from Application::Writes::Build so that Publish can reuse
9
- # it without creating a Build dependency.
10
- class Materializer
11
- def initialize(ctx:, manifest:, file_store:, bus:, root:, store:)
12
- @ctx = ctx
13
- @manifest = manifest
14
- @file_store = file_store
15
- @bus = bus
16
- @root = root
17
- @store = store
18
- end
19
-
20
- # Runs the builder pipeline for `mentry` and returns the on-disk
21
- # target_path string.
22
- def run(mentry)
23
- reader = Textus::Application::Reads::Get.new(
24
- ctx: @ctx, manifest: @manifest, file_store: @file_store,
25
- )
26
- lister = Textus::Application::Reads::List.new(manifest: @manifest)
27
- Builder::Pipeline.run(
28
- mentry: mentry,
29
- manifest: @manifest,
30
- reader: reader.method(:call),
31
- lister: lister.method(:call),
32
- transform_resolver: ->(name) { @bus.rpc_callable(:transform_rows, name) },
33
- template_loader: ->(name) { read_template(name) },
34
- transform_context: @store,
35
- inject_boot: -> { Textus::Boot.run(@store) },
36
- )
37
- end
38
-
39
- private
40
-
41
- def read_template(name)
42
- tpl_path = File.join(@root, "templates", name)
43
- raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
44
-
45
- File.read(tpl_path)
46
- end
47
- end
48
- end
49
- end
50
- end
@@ -1,105 +0,0 @@
1
- module Textus
2
- module Application
3
- module Writes
4
- class Mv
5
- def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
6
- @ctx = ctx
7
- @manifest = manifest
8
- @envelope_io = envelope_io
9
- @bus = bus
10
- @authorizer = authorizer
11
- @hook_context = hook_context
12
- end
13
-
14
- def call(old_key, new_key, dry_run: false)
15
- old_res, new_res = prepare(old_key, new_key)
16
- return dry_run_result(old_key, new_key, old_res, new_res) if dry_run
17
-
18
- ensure_uid!(old_key, old_res.entry)
19
- envelope = @envelope_io.move(
20
- from_key: old_key, to_key: new_key,
21
- new_mentry: new_res.entry
22
- )
23
- publish_renamed(old_key, new_key, envelope)
24
- success_result(old_key, new_key, old_res, new_res, envelope)
25
- end
26
-
27
- private
28
-
29
- def prepare(old_key, new_key)
30
- @manifest.validate_key!(old_key)
31
- @manifest.validate_key!(new_key)
32
- raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
33
-
34
- old_res = @manifest.resolver.resolve(old_key)
35
- new_res = @manifest.resolver.resolve(new_key)
36
- raise UnknownKey.new(old_key) unless @envelope_io.exists?(old_res.path)
37
-
38
- validate_zone_and_format!(old_res.entry, new_res.entry)
39
- @authorizer.authorize_write!(old_res.entry, role: @ctx.role)
40
- @authorizer.authorize_write!(new_res.entry, role: @ctx.role)
41
- raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if @envelope_io.exists?(new_res.path)
42
-
43
- [old_res, new_res]
44
- end
45
-
46
- def validate_zone_and_format!(old_mentry, new_mentry)
47
- if old_mentry.zone != new_mentry.zone
48
- raise UsageError.new(
49
- "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
50
- "Use put+delete for cross-zone moves.",
51
- )
52
- end
53
- return if old_mentry.format == new_mentry.format
54
-
55
- raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
56
- end
57
-
58
- # If the source file lacks a UID, rewrite it in-place via EnvelopeIO#write
59
- # so a UID gets injected before the move. This replaces the previous
60
- # Put(suppress_events: true) bypass with a direct EnvelopeIO call —
61
- # producing one "put" audit row, then the "mv" row from EnvelopeIO#move.
62
- def ensure_uid!(old_key, old_mentry)
63
- pre_env = @envelope_io.read_envelope(old_key)
64
- return if pre_env.uid
65
-
66
- @envelope_io.write(
67
- old_key, mentry: old_mentry,
68
- payload: EnvelopeIO::Payload.new(
69
- meta: pre_env.meta, body: pre_env.body, content: pre_env.content,
70
- )
71
- )
72
- end
73
-
74
- def publish_renamed(old_key, new_key, envelope)
75
- @bus.publish(:entry_renamed,
76
- ctx: @hook_context,
77
- key: new_key,
78
- from_key: old_key,
79
- to_key: new_key,
80
- envelope: envelope)
81
- end
82
-
83
- def dry_run_result(old_key, new_key, old_res, new_res)
84
- pre_env = @envelope_io.read_envelope(old_key)
85
- {
86
- "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
87
- "from_key" => old_key, "to_key" => new_key,
88
- "from_path" => old_res.path, "to_path" => new_res.path,
89
- "uid" => pre_env.uid
90
- }
91
- end
92
-
93
- def success_result(old_key, new_key, old_res, new_res, envelope)
94
- {
95
- "protocol" => PROTOCOL, "ok" => true,
96
- "from_key" => old_key, "to_key" => new_key,
97
- "from_path" => old_res.path, "to_path" => new_res.path,
98
- "uid" => envelope.uid,
99
- "envelope" => envelope.to_h_for_wire
100
- }
101
- end
102
- end
103
- end
104
- end
105
- end
@@ -1,81 +0,0 @@
1
- module Textus
2
- module Application
3
- module Writes
4
- # Single-pass publish use case: dispatches polymorphically to each
5
- # entry's `publish_via` method. Derived entries materialize their body
6
- # via Materializer; Nested entries fan out via publish_each; Leaf and
7
- # Intake entries copy their stored body to publish_to targets. The
8
- # Publish layer owns wiring (context, accumulation) but not per-kind
9
- # logic.
10
- #
11
- # Return shape: { "protocol", "built", "published_leaves" }
12
- class Publish
13
- def initialize(ctx:, manifest:, file_store:, bus:, root:, store:, hook_context:) # rubocop:disable Metrics/ParameterLists
14
- @ctx = ctx
15
- @manifest = manifest
16
- @file_store = file_store
17
- @bus = bus
18
- @root = root
19
- @store = store
20
- @hook_context = hook_context
21
- end
22
-
23
- def call(prefix: nil)
24
- built = []
25
- leaves = []
26
- context = build_context
27
-
28
- @manifest.entries.each do |mentry|
29
- next if prefix && !entry_matches_prefix?(mentry, prefix)
30
-
31
- result = mentry.publish_via(context, prefix: prefix)
32
- next if result.nil?
33
-
34
- case result[:kind]
35
- when :built then built << result[:value]
36
- when :leaves then leaves.concat(result[:value])
37
- end
38
- end
39
-
40
- { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
41
- end
42
-
43
- private
44
-
45
- def build_context
46
- Textus::Manifest::Entry::Base::PublishContext.new(
47
- repo_root: File.dirname(@root),
48
- manifest: @manifest,
49
- file_store: @file_store,
50
- root: @root,
51
- store: @store,
52
- ctx: @ctx,
53
- bus: @bus,
54
- hook_context: @hook_context,
55
- reader: reader,
56
- emit: ->(event, **payload) { @bus.publish(event, ctx: @hook_context, **payload) },
57
- )
58
- end
59
-
60
- # Whether the entry should be processed for the given prefix filter.
61
- def entry_matches_prefix?(mentry, prefix)
62
- return true unless prefix
63
-
64
- case mentry
65
- when Textus::Manifest::Entry::Nested
66
- mentry.key.start_with?(prefix) ||
67
- prefix.start_with?("#{mentry.key}.")
68
- else
69
- mentry.key.start_with?(prefix)
70
- end
71
- end
72
-
73
- def reader
74
- @reader ||= Textus::Application::Reads::Get.new(
75
- ctx: @ctx, manifest: @manifest, file_store: @file_store,
76
- )
77
- end
78
- end
79
- end
80
- end
81
- end
@@ -1,37 +0,0 @@
1
- module Textus
2
- module Application
3
- module Writes
4
- class Put
5
- def initialize(ctx:, manifest:, envelope_io:, bus:, authorizer:, hook_context:)
6
- @ctx = ctx
7
- @manifest = manifest
8
- @envelope_io = envelope_io
9
- @bus = bus
10
- @authorizer = authorizer
11
- @hook_context = hook_context
12
- end
13
-
14
- def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
15
- @manifest.validate_key!(key)
16
- mentry = @manifest.resolver.resolve(key).entry
17
-
18
- @authorizer.authorize_write!(mentry, role: @ctx.role)
19
-
20
- envelope = @envelope_io.write(
21
- key,
22
- mentry: mentry,
23
- payload: Textus::Application::Writes::EnvelopeIO::Payload.new(meta: meta, body: body, content: content),
24
- if_etag: if_etag,
25
- )
26
-
27
- @bus.publish(:entry_put,
28
- ctx: @hook_context,
29
- key: key,
30
- envelope: envelope)
31
-
32
- envelope
33
- end
34
- end
35
- end
36
- end
37
- end