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
@@ -0,0 +1,34 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module Deps
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(caps:)
11
+ @manifest = caps.manifest
12
+ end
13
+
14
+ def call(key)
15
+ entry = @manifest.data.entries.find { |e| e.key == key } or return []
16
+ return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
17
+
18
+ src = entry.source
19
+ result = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
20
+ Array(src.select).compact
21
+ elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
22
+ Array(src.sources).compact
23
+ else
24
+ []
25
+ end
26
+ result.uniq
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ Textus::Application::UseCase.register(:deps, Textus::Application::Read::Deps, caps: :read)
@@ -0,0 +1,110 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ module Application
5
+ module Read
6
+ # Per-entry freshness report. Walks every entry declared in the manifest,
7
+ # consults `rules_for(key)` for a refresh rule, and reports the
8
+ # current status. Status is one of :fresh, :stale, :never_refreshed, or
9
+ # :no_policy.
10
+ module Freshness
11
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
12
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
13
+ end
14
+
15
+ class Impl
16
+ def initialize(ctx:, caps:, evaluator: Textus::Domain::Freshness::Evaluator)
17
+ @ctx = ctx
18
+ @manifest = caps.manifest
19
+ @file_store = caps.file_store
20
+ @evaluator = evaluator
21
+ @cache = {}
22
+ end
23
+
24
+ # Returns the soonest `next_due_at` across all entries with a refresh
25
+ # policy, as an ISO-8601 string, or nil if none.
26
+ def soonest_due(prefix: nil, zone: nil)
27
+ times = call(prefix: prefix, zone: zone)
28
+ .map { |r| r[:next_due_at] }
29
+ .compact
30
+ .map { |t| Time.parse(t) }
31
+ return nil if times.empty?
32
+
33
+ times.min.utc.iso8601
34
+ end
35
+
36
+ def call(prefix: nil, zone: nil)
37
+ rows = []
38
+ @manifest.data.entries.each do |mentry|
39
+ next if prefix && !mentry.key.start_with?(prefix)
40
+ next if zone && mentry.zone != zone
41
+
42
+ rows << row_for(mentry)
43
+ end
44
+ rows
45
+ end
46
+
47
+ private
48
+
49
+ def row_for(mentry)
50
+ set = @manifest.rules.for(mentry.key)
51
+ refresh = set.refresh
52
+ envelope = safe_get(mentry.key)
53
+ last = envelope&.meta&.dig("last_refreshed_at")
54
+
55
+ return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
56
+
57
+ fp = refresh.to_freshness_policy
58
+ cache_key = [mentry.key, last]
59
+ verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @ctx.now))
60
+ status = if verdict.fresh? then :fresh
61
+ elsif last.nil? then :never_refreshed
62
+ else :stale
63
+ end
64
+
65
+ base_row(mentry, last).merge(
66
+ ttl_seconds: fp.ttl_seconds,
67
+ on_stale: fp.on_stale,
68
+ status: status,
69
+ next_due_at: next_due(last, fp.ttl_seconds),
70
+ )
71
+ end
72
+
73
+ def base_row(mentry, last)
74
+ {
75
+ key: mentry.key,
76
+ zone: mentry.zone,
77
+ last_refreshed_at: last,
78
+ age_seconds: last ? (@ctx.now - Time.parse(last)).to_i : nil,
79
+ }
80
+ end
81
+
82
+ # Returns the raw envelope or nil. Nested entries (mentry.key is a
83
+ # prefix, not a leaf) and missing files both resolve to nil.
84
+ def safe_get(key)
85
+ res = @manifest.resolver.resolve(key)
86
+ return nil unless @file_store.exists?(res.path)
87
+
88
+ raw = @file_store.read(res.path)
89
+ parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
90
+ Textus::Envelope.build(
91
+ key: key, mentry: res.entry, path: res.path,
92
+ meta: parsed["_meta"], body: parsed["body"],
93
+ etag: Etag.for_bytes(raw), content: parsed["content"]
94
+ )
95
+ rescue Textus::Error
96
+ nil
97
+ end
98
+
99
+ def next_due(last, ttl)
100
+ return nil if last.nil? || ttl.nil?
101
+
102
+ (Time.parse(last) + ttl).utc.iso8601
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ Textus::Application::UseCase.register(:freshness, Textus::Application::Read::Freshness, caps: :read)
@@ -0,0 +1,75 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ # Pure read: returns the on-disk envelope annotated with a freshness
5
+ # verdict. Never triggers refresh; never invokes the orchestrator.
6
+ #
7
+ # For interactive reads that want refresh-on-stale, use
8
+ # `Read::GetOrRefresh`, which composes this with the orchestrator.
9
+ module Get
10
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
11
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:, evaluator: Textus::Domain::Freshness::Evaluator)
16
+ @ctx = ctx
17
+ @manifest = caps.manifest
18
+ @file_store = caps.file_store
19
+ @evaluator = evaluator
20
+ end
21
+
22
+ def call(key)
23
+ envelope = read_raw_envelope(key)
24
+ return nil if envelope.nil?
25
+
26
+ policy_set = @manifest.rules.for(key)
27
+ refresh_policy = policy_set.refresh
28
+ return annotate_fresh(envelope) if refresh_policy.nil?
29
+
30
+ policy = refresh_policy.to_freshness_policy
31
+ verdict = @evaluator.call(policy, envelope, now: @ctx.now)
32
+
33
+ envelope.with(freshness: Textus::Domain::Freshness.build(
34
+ stale: verdict.stale?,
35
+ reason: verdict.reason,
36
+ refreshing: false,
37
+ ))
38
+ end
39
+
40
+ # Strict variant: raises UnknownKey when the entry is missing.
41
+ # Used by consumers (e.g. Validator) that need to distinguish absence
42
+ # from emptiness.
43
+ def get(key)
44
+ call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
45
+ end
46
+
47
+ private
48
+
49
+ def read_raw_envelope(key)
50
+ res = @manifest.resolver.resolve(key)
51
+ mentry = res.entry
52
+ path = res.path
53
+ return nil unless @file_store.exists?(path)
54
+
55
+ raw = @file_store.read(path)
56
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
57
+ Textus::Envelope.build(
58
+ key: key, mentry: mentry, path: path,
59
+ meta: parsed["_meta"], body: parsed["body"],
60
+ etag: Etag.for_bytes(raw), content: parsed["content"]
61
+ )
62
+ end
63
+
64
+ def annotate_fresh(envelope)
65
+ envelope.with(freshness: Textus::Domain::Freshness.build(
66
+ stale: false, reason: nil, refreshing: false,
67
+ ))
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
@@ -0,0 +1,63 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ # Composes pure `Read::Get` with the refresh orchestrator: runs Get
5
+ # to obtain the envelope and freshness verdict, then if the verdict
6
+ # is stale and the rule's `on_stale` policy demands action, hands
7
+ # off to the orchestrator. Use for interactive reads where the
8
+ # caller wants the freshest obtainable envelope.
9
+ #
10
+ # Pure reads (build, projection, schema tooling) should use
11
+ # `Read::Get` directly; it has no orchestrator dependency.
12
+ module GetOrRefresh
13
+ def self.call(*, session:, ctx:, caps:, **)
14
+ Impl.new(
15
+ caps: caps,
16
+ get: Read::Get::Impl.new(ctx: ctx, caps: caps),
17
+ orchestrator: session.refresh_orchestrator,
18
+ ).call(*, **)
19
+ end
20
+
21
+ class Impl
22
+ def initialize(caps:, get:, orchestrator:)
23
+ @manifest = caps.manifest
24
+ @get = get
25
+ @orchestrator = orchestrator
26
+ end
27
+
28
+ def call(key)
29
+ envelope = @get.call(key)
30
+ return nil if envelope.nil?
31
+ return envelope unless envelope.freshness&.stale
32
+
33
+ policy_set = @manifest.rules.for(key)
34
+ refresh_policy = policy_set.refresh
35
+ return envelope if refresh_policy.nil?
36
+
37
+ policy = refresh_policy.to_freshness_policy
38
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
39
+ action = policy.decide(verdict)
40
+ outcome = @orchestrator.execute(action, key: key)
41
+
42
+ case outcome
43
+ when Textus::Domain::Outcome::Skipped
44
+ envelope
45
+ when Textus::Domain::Outcome::Refreshed
46
+ outcome.envelope.with(
47
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
48
+ )
49
+ when Textus::Domain::Outcome::Detached
50
+ envelope.with(freshness: envelope.freshness.with(refreshing: true))
51
+ when Textus::Domain::Outcome::Failed
52
+ envelope.with(
53
+ freshness: envelope.freshness.with(refresh_error: outcome.error.message),
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ Textus::Application::UseCase.register(:get_or_refresh, Textus::Application::Read::GetOrRefresh, caps: :write)
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module List
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(caps:)
11
+ @manifest = caps.manifest
12
+ end
13
+
14
+ def call(prefix: nil, zone: nil)
15
+ rows = @manifest.resolver.enumerate(prefix: prefix)
16
+ rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
17
+ rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ Textus::Application::UseCase.register(:list, Textus::Application::Read::List, caps: :read)
@@ -0,0 +1,47 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ # For one key, surface every matching policy block along with the
5
+ # per-slot effective value (which loses ties win-by-specificity).
6
+ module PolicyExplain
7
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
8
+ Impl.new(caps: caps).call(*, **)
9
+ end
10
+
11
+ class Impl
12
+ def initialize(caps:)
13
+ @manifest = caps.manifest
14
+ end
15
+
16
+ def call(key:)
17
+ policies = @manifest.rules
18
+ matching = policies.explain(key)
19
+ winners = policies.for(key)
20
+
21
+ {
22
+ key: key,
23
+ matched_blocks: matching.map do |b|
24
+ {
25
+ match: b.match,
26
+ refresh: !b.refresh.nil?,
27
+ handler_allowlist: !b.handler_allowlist.nil?,
28
+ promote: !b.promote.nil?,
29
+ }
30
+ end,
31
+ effective: {
32
+ refresh: winners.refresh && {
33
+ ttl_seconds: winners.refresh.ttl_seconds,
34
+ on_stale: winners.refresh.on_stale,
35
+ },
36
+ handler_allowlist: winners.handler_allowlist&.handlers,
37
+ promotion: winners.promote && { requires: winners.promote.requires },
38
+ },
39
+ }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ Textus::Application::UseCase.register(:policy_explain, Textus::Application::Read::PolicyExplain, caps: :read)
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module Published
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(caps:)
11
+ @manifest = caps.manifest
12
+ end
13
+
14
+ def call
15
+ @manifest.data.entries.reject { |e| e.publish_to.empty? }.map do |e|
16
+ { "key" => e.key, "publish_to" => e.publish_to }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ Textus::Application::UseCase.register(:published, Textus::Application::Read::Published, caps: :read)
@@ -0,0 +1,101 @@
1
+ require "digest"
2
+ require "time"
3
+
4
+ module Textus
5
+ module Application
6
+ module Read
7
+ # Aggregator over audit + freshness + review + doctor. One round-trip
8
+ # for an agent's per-turn heartbeat. All component reads are existing
9
+ # APIs; pulse is sugar with a stable envelope shape and a monotonic
10
+ # cursor (seq).
11
+ module Pulse
12
+ def self.call(*, session:, ctx:, caps:, **)
13
+ Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
14
+ end
15
+
16
+ class Impl
17
+ def initialize(ctx:, caps:, session:)
18
+ @ctx = ctx
19
+ @caps = caps
20
+ @manifest = caps.manifest
21
+ @file_store = caps.file_store
22
+ @audit_log = caps.audit_log
23
+ @root = caps.root
24
+ @events = caps.events
25
+ @session = session
26
+ end
27
+
28
+ def call(since: 0)
29
+ freshness_rows = freshness.call
30
+ {
31
+ "cursor" => @audit_log.latest_seq,
32
+ "changed" => audit_changes_since(since),
33
+ "stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
34
+ "pending_review" => review_keys,
35
+ "doctor" => doctor_summary,
36
+ "manifest_etag" => manifest_etag,
37
+ "next_due_at" => soonest_due(freshness_rows),
38
+ "hook_errors" => hook_errors_since(since),
39
+ }
40
+ end
41
+
42
+ private
43
+
44
+ def audit_changes_since(seq)
45
+ Read::Audit::Impl.new(caps: @caps).call(seq_since: seq)
46
+ end
47
+
48
+ def freshness
49
+ @freshness ||= Read::Freshness::Impl.new(ctx: @ctx, caps: @caps)
50
+ end
51
+
52
+ def soonest_due(rows)
53
+ times = rows.map { |r| r[:next_due_at] }.compact.map { |t| Time.parse(t) }
54
+ return nil if times.empty?
55
+
56
+ times.min.utc.iso8601
57
+ end
58
+
59
+ def review_keys
60
+ # List constructor takes only manifest:; returns hashes with string keys.
61
+ # Guard: zones is a Hash keyed by name string.
62
+ return [] unless @manifest.data.zones.key?("review")
63
+
64
+ rows = Read::List::Impl.new(caps: @caps).call(zone: "review")
65
+ rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
66
+ end
67
+
68
+ def doctor_summary
69
+ result = Textus::Doctor.run(@session)
70
+ issues = result["issues"] || []
71
+ {
72
+ "ok" => result["ok"],
73
+ "warn" => issues.count { |i| i["level"] == "warning" },
74
+ "fail" => issues.count { |i| i["level"] == "error" },
75
+ }
76
+ end
77
+
78
+ def manifest_etag
79
+ Digest::SHA256.hexdigest(File.read(File.join(@root, "manifest.yaml")))
80
+ end
81
+
82
+ def hook_errors_since(seq)
83
+ @events.error_log.since(seq).map do |r|
84
+ {
85
+ "seq" => r[:seq],
86
+ "event" => r[:event].to_s,
87
+ "hook" => r[:hook].to_s,
88
+ "key" => r[:key],
89
+ "error_class" => r[:error_class],
90
+ "error_message" => r[:error_message],
91
+ "at" => r[:at],
92
+ }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ Textus::Application::UseCase.register(:pulse, Textus::Application::Read::Pulse, caps: :read)
@@ -0,0 +1,35 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module Rdeps
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(caps:)
11
+ @manifest = caps.manifest
12
+ end
13
+
14
+ def call(key)
15
+ @manifest.data.entries.each_with_object([]) do |e, acc|
16
+ next unless e.is_a?(Textus::Manifest::Entry::Derived)
17
+
18
+ src = e.source
19
+ sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
20
+ Array(src.select).compact
21
+ elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
22
+ Array(src.sources).compact
23
+ else
24
+ []
25
+ end
26
+ acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ Textus::Application::UseCase.register(:rdeps, Textus::Application::Read::Rdeps, caps: :read)
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module SchemaEnvelope
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(caps:)
11
+ @manifest = caps.manifest
12
+ @schemas = caps.schemas
13
+ end
14
+
15
+ def call(key)
16
+ mentry = @manifest.resolver.resolve(key).entry
17
+ schema = @schemas.fetch_or_nil(mentry.schema)
18
+ { "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ Textus::Application::UseCase.register(:schema_envelope, Textus::Application::Read::SchemaEnvelope, caps: :read)
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module Stale
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(caps:)
11
+ @manifest = caps.manifest
12
+ end
13
+
14
+ def call(prefix: nil, zone: nil)
15
+ Textus::Domain::Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ Textus::Application::UseCase.register(:stale, Textus::Application::Read::Stale, caps: :read)
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module Uid
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(ctx:, caps:)
11
+ @ctx = ctx
12
+ @caps = caps
13
+ end
14
+
15
+ def call(key)
16
+ get.get(key).uid
17
+ end
18
+
19
+ private
20
+
21
+ def get
22
+ @get ||= Get::Impl.new(ctx: @ctx, caps: @caps)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ Textus::Application::UseCase.register(:uid, Textus::Application::Read::Uid, caps: :read)
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module ValidateAll
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(ctx:, caps:)
11
+ @ctx = ctx
12
+ @caps = caps
13
+ @manifest = caps.manifest
14
+ @schemas = caps.schemas
15
+ @audit_log = caps.audit_log
16
+ end
17
+
18
+ def call
19
+ Validator.new(
20
+ reader: Get::Impl.new(ctx: @ctx, caps: @caps),
21
+ manifest: @manifest,
22
+ audit_log: @audit_log,
23
+ schema_for: ->(name) { @schemas.fetch_or_nil(name) },
24
+ ).call
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ Textus::Application::UseCase.register(:validate_all, Textus::Application::Read::ValidateAll, caps: :read)
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  module Application
3
- module Reads
3
+ module Read
4
4
  class Validator
5
5
  def initialize(reader:, manifest:, audit_log:, schema_for:)
6
6
  @reader = reader
@@ -55,7 +55,7 @@ module Textus
55
55
  last_writer = @audit_log.last_writer_for(key)
56
56
  return if last_writer.nil?
57
57
 
58
- last_writer_is_authority = @manifest.role_kind(last_writer) == :accept_authority
58
+ last_writer_is_authority = @manifest.policy.role_kind(last_writer) == :accept_authority
59
59
 
60
60
  env.meta.each_key do |field|
61
61
  owner = schema.maintained_by(field)
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Application
3
+ module Read
4
+ module Where
5
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
6
+ Impl.new(caps: caps).call(*, **)
7
+ end
8
+
9
+ class Impl
10
+ def initialize(caps:)
11
+ @manifest = caps.manifest
12
+ end
13
+
14
+ def call(key)
15
+ res = @manifest.resolver.resolve(key)
16
+ mentry = res.entry
17
+ path = res.path
18
+ { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ Textus::Application::UseCase.register(:where, Textus::Application::Read::Where, caps: :read)