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
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ module Application
3
+ module Write
4
+ module Put
5
+ def self.call(*, session:, ctx:, caps:, **)
6
+ Impl.new(
7
+ ctx: ctx, caps: caps,
8
+ writer: session.envelope_writer,
9
+ hook_context: session.hook_context
10
+ ).call(*, **)
11
+ end
12
+
13
+ class Impl
14
+ def initialize(ctx:, caps:, writer:, hook_context:)
15
+ @ctx = ctx
16
+ @manifest = caps.manifest
17
+ @events = caps.events
18
+ @authorizer = caps.authorizer
19
+ @writer = writer
20
+ @hook_context = hook_context
21
+ end
22
+
23
+ def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
24
+ Textus::Manifest::Data.validate_key!(key)
25
+ mentry = @manifest.resolver.resolve(key).entry
26
+
27
+ @authorizer.authorize_write!(mentry, role: @ctx.role)
28
+
29
+ envelope = @writer.put(
30
+ key,
31
+ mentry: mentry,
32
+ payload: Textus::Application::Envelope::Writer::Payload.new(meta: meta, body: body, content: content),
33
+ if_etag: if_etag,
34
+ )
35
+
36
+ @events.publish(:entry_put,
37
+ ctx: @hook_context,
38
+ key: key,
39
+ envelope: envelope)
40
+
41
+ envelope
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ Textus::Application::UseCase.register(:put, Textus::Application::Write::Put, caps: :write)
@@ -0,0 +1,63 @@
1
+ module Textus
2
+ module Application
3
+ module Write
4
+ module RefreshAll
5
+ def self.call(*, session:, ctx:, caps:, **)
6
+ Impl.new(
7
+ ctx: ctx, caps: caps,
8
+ rpc: session.rpc,
9
+ writer: session.envelope_writer,
10
+ hook_context: session.hook_context
11
+ ).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:, rpc:, writer:, hook_context:)
16
+ @ctx = ctx
17
+ @caps = caps
18
+ @rpc = rpc
19
+ @writer = writer
20
+ @hook_context = hook_context
21
+ end
22
+
23
+ def call(prefix: nil, zone: nil)
24
+ worker = Textus::Application::Write::RefreshWorker::Impl.new(
25
+ ctx: @ctx, caps: @caps, rpc: @rpc, writer: @writer,
26
+ hook_context: @hook_context
27
+ )
28
+
29
+ stale_rows = Textus::Application::Read::Stale::Impl.new(caps: @caps).call(prefix: prefix, zone: zone)
30
+ refreshed = []
31
+ failed = []
32
+ skipped = []
33
+
34
+ stale_rows.each do |row|
35
+ key = row["key"] || row[:key]
36
+ reason = row["reason"] || row[:reason]
37
+ if reason.to_s.match?(/ttl exceeded|never refreshed/)
38
+ begin
39
+ worker.run(key)
40
+ refreshed << key
41
+ rescue Textus::Error => e
42
+ failed << { "key" => key, "error" => e.message }
43
+ end
44
+ else
45
+ skipped << { "key" => key, "reason" => reason }
46
+ end
47
+ end
48
+
49
+ {
50
+ "protocol" => Textus::PROTOCOL,
51
+ "ok" => failed.empty?,
52
+ "refreshed" => refreshed,
53
+ "failed" => failed,
54
+ "skipped" => skipped,
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ Textus::Application::UseCase.register(:refresh_all, Textus::Application::Write::RefreshAll, caps: :write)
@@ -1,12 +1,11 @@
1
1
  module Textus
2
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
3
+ module Write
4
+ class RefreshOrchestrator
5
+ def initialize(worker:, store_root:, events:, ctx: nil, hook_context: nil, detached_spawner: nil)
6
6
  @worker = worker
7
7
  @store_root = store_root
8
- @bus = bus
9
- @store = store
8
+ @events = events
10
9
  @ctx = ctx
11
10
  @hook_context = hook_context
12
11
  @detached_spawner = detached_spawner || default_spawner
@@ -31,12 +30,37 @@ module Textus
31
30
  end
32
31
 
33
32
  def run_timed(budget_ms, key)
34
- unless Textus::Infra::Refresh::Detached.supported?
33
+ return run_timed_with_fork(budget_ms, key) if Textus::Infra::Refresh::Detached.supported?
34
+
35
+ run_timed_cooperative(budget_ms, key)
36
+ end
37
+
38
+ def run_timed_cooperative(budget_ms, key)
39
+ result = nil
40
+ thread = Thread.new do
41
+ result = @worker.run(key)
42
+ rescue Textus::Error => e
43
+ result = e
44
+ end
45
+
46
+ thread.join(budget_ms / 1000.0)
47
+ if thread.alive?
48
+ thread.kill
35
49
  return Textus::Domain::Outcome::Failed.new(
36
- error: Textus::UsageError.new("timed_sync requires fork (Unix only)"),
50
+ error: Textus::UsageError.new(
51
+ "refresh exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
52
+ ),
37
53
  )
38
54
  end
39
55
 
56
+ if result.is_a?(Textus::Error)
57
+ Textus::Domain::Outcome::Failed.new(error: result)
58
+ else
59
+ Textus::Domain::Outcome::Refreshed.new(envelope: result)
60
+ end
61
+ end
62
+
63
+ def run_timed_with_fork(budget_ms, key)
40
64
  result = nil
41
65
  thread = Thread.new do
42
66
  result = @worker.run(key)
@@ -59,7 +83,7 @@ module Textus
59
83
 
60
84
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
61
85
  payload[:ctx] = @hook_context if @hook_context
62
- @bus&.publish(:refresh_backgrounded, **payload)
86
+ @events.publish(:refresh_backgrounded, **payload)
63
87
  @detached_spawner.call(store_root: @store_root, key: key)
64
88
  Textus::Domain::Outcome::Detached.new
65
89
  elsif result.is_a?(Textus::Error)
@@ -0,0 +1,134 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Application
5
+ module Write
6
+ module RefreshWorker
7
+ FETCH_TIMEOUT_SECONDS = 30
8
+
9
+ def self.call(*, session:, ctx:, caps:, **)
10
+ Impl.new(
11
+ ctx: ctx, caps: caps,
12
+ rpc: session.rpc,
13
+ writer: session.envelope_writer,
14
+ hook_context: session.hook_context
15
+ ).call(*, **)
16
+ end
17
+
18
+ class Impl
19
+ def initialize(ctx:, caps:, rpc:, writer:, hook_context:)
20
+ @ctx = ctx
21
+ @caps = caps
22
+ @manifest = caps.manifest
23
+ @writer = writer
24
+ @events = caps.events
25
+ @rpc = rpc
26
+ @authorizer = caps.authorizer
27
+ @hook_context = hook_context
28
+ end
29
+
30
+ # call(key) is the primary entry; run is kept as an alias for
31
+ # Orchestrator and RefreshAll which call worker.run(key).
32
+ def call(key)
33
+ run(key)
34
+ end
35
+
36
+ def run(key)
37
+ res = @manifest.resolver.resolve(key)
38
+ mentry = res.entry
39
+ path = res.path
40
+ remaining = res.remaining
41
+ raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
42
+
43
+ before_etag = File.exist?(path) ? Etag.for_file(path) : nil
44
+ result = fetch_with_events(key, mentry, remaining)
45
+ persist_and_notify(key, mentry, result, before_etag)
46
+ end
47
+
48
+ private
49
+
50
+ def fetch_timeout_for(key)
51
+ rule = @manifest.rules.for(key)
52
+ rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
53
+ end
54
+
55
+ def fetch_with_events(key, mentry, remaining)
56
+ @events.publish(:refresh_started, ctx: @hook_context, key: key, mode: :sync)
57
+ call_intake(key, mentry, remaining)
58
+ end
59
+
60
+ def call_intake(key, mentry, remaining)
61
+ timeout = fetch_timeout_for(key)
62
+ Timeout.timeout(timeout) do
63
+ @rpc.invoke(:resolve_intake, mentry.handler,
64
+ caps: @caps,
65
+ config: mentry.config,
66
+ args: { trigger_key: key, leaf_segments: remaining || [] })
67
+ end
68
+ rescue Timeout::Error
69
+ @events.publish(:refresh_failed, ctx: @hook_context, key: key,
70
+ error_class: "Timeout::Error",
71
+ error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
72
+ raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
73
+ rescue Textus::Error => e
74
+ @events.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
75
+ error_message: e.message)
76
+ raise
77
+ rescue StandardError => e
78
+ @events.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
79
+ error_message: e.message)
80
+ raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
81
+ end
82
+
83
+ def persist_and_notify(key, mentry, result, before_etag)
84
+ normalized = RefreshWorker.send(:normalize_action_result, result, format: mentry.format)
85
+ @authorizer.authorize_write!(mentry, role: @ctx.role)
86
+ envelope = @writer.put(
87
+ key,
88
+ mentry: mentry,
89
+ payload: Textus::Application::Envelope::Writer::Payload.new(
90
+ meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
91
+ ),
92
+ )
93
+ change = detect_change(before_etag, envelope)
94
+ @events.publish(:entry_refreshed, ctx: @hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
95
+ envelope
96
+ end
97
+
98
+ def detect_change(before_etag, envelope)
99
+ if before_etag.nil? then :created
100
+ elsif envelope.etag == before_etag then :unchanged
101
+ else :updated
102
+ end
103
+ end
104
+ end
105
+
106
+ def self.normalize_action_result(res, format:)
107
+ res = res.transform_keys(&:to_s) if res.is_a?(Hash)
108
+ res ||= {}
109
+ meta_val = res["_meta"]
110
+ body = res["body"]
111
+ content = res["content"]
112
+
113
+ case format
114
+ when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
115
+ when "text" then { meta: {}, body: body.to_s, content: nil }
116
+ when "json", "yaml"
117
+ if !content.nil?
118
+ { meta: meta_val || {}, body: nil, content: content }
119
+ elsif !body.nil?
120
+ { meta: {}, body: body.to_s, content: nil }
121
+ else
122
+ raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
123
+ end
124
+ else
125
+ raise Textus::UsageError.new("unknown format #{format.inspect}")
126
+ end
127
+ end
128
+ private_class_method :normalize_action_result
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ Textus::Application::UseCase.register(:refresh, Textus::Application::Write::RefreshWorker, caps: :write)
@@ -0,0 +1,62 @@
1
+ require_relative "authority_gate"
2
+
3
+ module Textus
4
+ module Application
5
+ module Write
6
+ module Reject
7
+ def self.call(*, session:, ctx:, caps:, **)
8
+ Impl.new(
9
+ ctx: ctx, caps: caps,
10
+ writer: session.envelope_writer,
11
+ hook_context: session.hook_context
12
+ ).call(*, **)
13
+ end
14
+
15
+ class Impl
16
+ include AuthorityGate
17
+
18
+ def initialize(ctx:, caps:, writer:, hook_context:)
19
+ @ctx = ctx
20
+ @caps = caps
21
+ @manifest = caps.manifest
22
+ @writer = writer
23
+ @events = caps.events
24
+ @authorizer = caps.authorizer
25
+ @hook_context = hook_context
26
+ end
27
+
28
+ def call(pending_key)
29
+ assert_accept_authority!("reject")
30
+
31
+ mentry = @manifest.resolver.resolve(pending_key).entry
32
+ unless mentry.in_proposal_zone?
33
+ raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
34
+ end
35
+
36
+ env = Textus::Application::Read::Get::Impl.new(
37
+ ctx: @ctx, caps: @caps,
38
+ ).call(pending_key)
39
+ proposal = env.meta&.dig("proposal") or
40
+ raise ProposalError.new("entry has no proposal block: #{pending_key}")
41
+ target_key = proposal["target_key"] or
42
+ raise ProposalError.new("proposal missing target_key")
43
+
44
+ Textus::Application::Write::Delete::Impl.new(
45
+ ctx: @ctx, caps: @caps, writer: @writer,
46
+ hook_context: @hook_context
47
+ ).call(pending_key, suppress_events: true)
48
+
49
+ @events.publish(:proposal_rejected,
50
+ ctx: @hook_context,
51
+ key: pending_key,
52
+ target_key: target_key)
53
+
54
+ { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ Textus::Application::UseCase.register(:reject, Textus::Application::Write::Reject, caps: :write)
@@ -4,8 +4,8 @@ module Textus
4
4
  # project: zones and their write authority, entries and their flags,
5
5
  # registered hooks, write flows, and the CLI verb catalog.
6
6
  #
7
- # Intro is side-effect-free.
8
- module Intro
7
+ # Boot is side-effect-free.
8
+ module Boot
9
9
  PROTOCOL_ID = PROTOCOL
10
10
 
11
11
  # Conventional zone purposes. Unknown zones (declared in the manifest
@@ -26,7 +26,7 @@ module Textus
26
26
  "edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
27
27
  end,
28
28
  proposer: lambda do |name, manifest|
29
- authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
29
+ authority = manifest.policy.roles_with_kind(:accept_authority).first || "accept_authority"
30
30
  "propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
31
31
  "the #{authority} role runs 'textus accept' to apply"
32
32
  end,
@@ -39,7 +39,7 @@ module Textus
39
39
  }.freeze
40
40
 
41
41
  def self.write_flows_for(manifest)
42
- manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
42
+ manifest.policy.role_mapping.each_with_object({}) do |(name, kind), acc|
43
43
  tmpl = WRITE_FLOW_TEMPLATES[kind]
44
44
  acc[name] = tmpl.call(name, manifest) if tmpl
45
45
  end
@@ -95,10 +95,10 @@ module Textus
95
95
  }.freeze
96
96
 
97
97
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
98
- # Agents that read intro should see a stable shape regardless of how
98
+ # Agents that read boot should see a stable shape regardless of how
99
99
  # verb implementations evolve.
100
100
  CLI_VERBS = [
101
- { "name" => "intro", "summary" => "this output — orientation for agents and tools" },
101
+ { "name" => "boot", "summary" => "this output — orientation for agents and tools" },
102
102
  { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
103
103
  { "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
104
104
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
@@ -116,35 +116,58 @@ module Textus
116
116
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
117
117
  { "name" => "hook",
118
118
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
119
+ { "name" => "pulse",
120
+ "summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
119
121
  ].freeze
120
122
 
123
+ def self.agent_quickstart(manifest, session)
124
+ proposer_roles = manifest.policy.roles_with_kind(:proposer)
125
+ agent_role = proposer_roles.first
126
+
127
+ writable_zones = manifest.data.zones.each_with_object([]) do |(zname, writers), acc|
128
+ acc << zname if agent_role && writers.include?(agent_role)
129
+ end
130
+
131
+ propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
132
+
133
+ {
134
+ "read_verbs" => %w[boot get list audit pulse freshness doctor],
135
+ "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
136
+ "writable_zones" => writable_zones,
137
+ "propose_zone" => propose_zone,
138
+ "latest_seq" => session.write_caps.audit_log.latest_seq,
139
+ }
140
+ end
141
+
121
142
  def self.agent_protocol(manifest)
122
143
  AGENT_PROTOCOL_TEMPLATE.merge(
123
144
  "role_resolution" => {
124
145
  "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
125
146
  "default 'human'",
126
- "roles" => manifest.role_mapping.keys,
147
+ "roles" => manifest.policy.role_mapping.keys,
127
148
  "ref" => "SPEC.md §5",
128
149
  },
129
150
  )
130
151
  end
131
152
 
132
- def self.run(store)
153
+ def self.run(session)
154
+ manifest = session.read_caps.manifest
133
155
  {
134
156
  "protocol" => PROTOCOL_ID,
135
- "store_root" => store.root,
136
- "zones" => zones_for(store),
137
- "entries" => entries_for(store),
138
- "hooks" => hooks_for(store),
139
- "write_flows" => write_flows_for(store.manifest),
157
+ "store_root" => session.read_caps.root,
158
+ "zones" => zones_for(manifest),
159
+ "entries" => entries_for(manifest),
160
+ "hooks" => hooks_for(session),
161
+ "write_flows" => write_flows_for(manifest),
140
162
  "cli_verbs" => CLI_VERBS.map(&:dup),
141
- "agent_protocol" => agent_protocol(store.manifest),
163
+ "agent_protocol" => agent_protocol(manifest),
164
+ "agent_quickstart" => agent_quickstart(manifest, session),
142
165
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
143
166
  }
144
167
  end
145
168
 
146
- def self.zones_for(store)
147
- store.manifest.zones.map do |name, writers|
169
+ def self.zones_for(manifest)
170
+ manifest.data.zones.map do |name, writers|
148
171
  row = { "name" => name, "writers" => Array(writers) }
149
172
  purpose = ZONE_PURPOSES[name]
150
173
  row["purpose"] = purpose if purpose
@@ -152,9 +175,9 @@ module Textus
152
175
  end
153
176
  end
154
177
 
155
- def self.entries_for(store)
156
- store.manifest.entries.map do |e|
157
- derived = store.manifest.zone_kinds(e.zone).include?(:generator)
178
+ def self.entries_for(manifest)
179
+ manifest.data.entries.map do |e|
180
+ derived = manifest.policy.zone_kinds(e.zone).include?(:generator)
158
181
  {
159
182
  "key" => e.key,
160
183
  "zone" => e.zone,
@@ -165,21 +188,18 @@ module Textus
165
188
  "derived" => derived,
166
189
  "intake" => e.is_a?(Textus::Manifest::Entry::Intake),
167
190
  "publish_to" => Array(e.publish_to),
168
- "publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
191
+ "publish_each" => e.publish_each,
169
192
  }
170
193
  end
171
194
  end
172
195
 
173
- def self.hooks_for(store)
174
- bus = store.bus
196
+ def self.hooks_for(session)
175
197
  sections = {}
176
- Hooks::Bus::EVENTS.each do |event, spec|
177
- case spec[:mode]
178
- when :rpc
179
- sections[event.to_s] = bus.rpc_names(event).map(&:to_s).sort
180
- when :pubsub
181
- sections[event.to_s] = bus.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
182
- end
198
+ Hooks::RpcRegistry::EVENTS.each_key do |event|
199
+ sections[event.to_s] = session.rpc.names(event).map(&:to_s).sort
200
+ end
201
+ Hooks::EventBus::EVENTS.each_key do |event|
202
+ sections[event.to_s] = session.events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
183
203
  end
184
204
  sections
185
205
  end
@@ -63,8 +63,8 @@ module Textus
63
63
  end
64
64
 
65
65
  # rubocop:disable Metrics/ParameterLists
66
- def self.run(mentry:, manifest:, reader:, lister:, transform_resolver:, template_loader:,
67
- transform_context: nil, inject_intro: nil)
66
+ def self.run(mentry:, manifest:, reader:, lister:, rpc:, template_loader:,
67
+ transform_context: nil, inject_boot: nil)
68
68
  # 1. Load sources + project + reduce
69
69
  data =
70
70
  if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
@@ -72,13 +72,13 @@ module Textus
72
72
  reader: reader,
73
73
  spec: mentry.source.to_h.transform_keys(&:to_s),
74
74
  lister: lister,
75
- transform_resolver: transform_resolver,
75
+ rpc: rpc,
76
76
  transform_context: transform_context,
77
77
  ).run
78
78
  else
79
79
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
80
80
  end
81
- data = data.merge("intro" => inject_intro.call) if mentry.inject_intro && inject_intro
81
+ data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
82
82
 
83
83
  # 2. Render
84
84
  klass = renderers[mentry.format] or
@@ -86,7 +86,7 @@ module Textus
86
86
  bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
87
87
 
88
88
  # 3. Write (idempotent: skip if only generated_at would differ)
89
- target_path = Key::Path.resolve(manifest, mentry)
89
+ target_path = Key::Path.resolve(manifest.data, mentry)
90
90
  FileUtils.mkdir_p(File.dirname(target_path))
91
91
  write_if_changed(target_path, bytes, mentry.format)
92
92
 
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class MCP < Group
5
+ command_name "mcp"
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class Zone < Group
5
+ command_name "zone"
6
+ end
7
+ end
8
+ end
9
+ end
@@ -8,7 +8,7 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  key = positional.shift or raise UsageError.new("accept requires a key")
11
- emit(operations_for(store).accept(key))
11
+ emit(session_for(store).accept(key))
12
12
  end
13
13
  end
14
14
  end
@@ -9,18 +9,20 @@ module Textus
9
9
  option :role_filter, "--role=ROLE"
10
10
  option :verb_filter, "--verb=V"
11
11
  option :since, "--since=ISO8601|RELATIVE"
12
+ option :seq_since, "--seq-since=N"
12
13
  option :correlation_id, "--correlation-id=ID"
13
14
  option :limit, "--limit=N"
14
15
 
15
16
  def call(store)
16
- ops = operations_for(store)
17
- since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ops.ctx.now)
17
+ ops = session_for(store)
18
+ since_time = since && Textus::Application::Read::Audit.parse_since(since, now: ops.ctx.now)
18
19
  rows = ops.audit(
19
20
  key: key_filter,
20
21
  zone: zone,
21
22
  role: role_filter,
22
23
  verb: verb_filter,
23
24
  since: since_time,
25
+ seq_since: seq_since&.to_i,
24
26
  correlation_id: correlation_id,
25
27
  limit: limit&.to_i,
26
28
  )
@@ -8,7 +8,7 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  key = positional.shift or raise UsageError.new("blame requires a key")
11
- rows = operations_for(store).blame(key: key, limit: limit&.to_i)
11
+ rows = session_for(store).blame(key: key, limit: limit&.to_i)
12
12
  emit({ "verb" => "blame", "key" => key, "rows" => rows })
13
13
  end
14
14
  end
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Boot < Verb
5
+ command_name "boot"
6
+
7
+ def call(store)
8
+ emit(Textus::Boot.run(Textus::Session.for(store)))
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -8,8 +8,8 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  Textus::Infra::BuildLock.with(root: store.root) do
11
- role = store.manifest.roles_with_kind(:generator).first || "builder"
12
- ops = Textus::Operations.for(store, role: role)
11
+ role = store.manifest.policy.roles_with_kind(:generator).first || "builder"
12
+ ops = store.session(role: role)
13
13
  result = ops.publish(prefix: prefix)
14
14
  emit(result)
15
15
  end
@@ -9,7 +9,7 @@ module Textus
9
9
 
10
10
  def call(store)
11
11
  key = positional.shift or raise UsageError.new("delete requires a key")
12
- emit(operations_for(store).delete(key, if_etag: if_etag))
12
+ emit(session_for(store).delete(key, if_etag: if_etag))
13
13
  end
14
14
  end
15
15
  end
@@ -6,7 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("deps requires a key")
9
- emit({ "key" => key, "deps" => operations_for(store).deps(key) })
9
+ emit({ "key" => key, "deps" => session_for(store).deps(key) })
10
10
  end
11
11
  end
12
12
  end