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,106 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Textus
5
+ module Application
6
+ module Read
7
+ # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
8
+ # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
9
+ # rows produce nil and are skipped).
10
+ module Audit
11
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
12
+ Impl.new(caps: caps).call(*, **)
13
+ end
14
+
15
+ def self.parse_since(str, now: Time.now.utc)
16
+ Impl.parse_since(str, now: now)
17
+ end
18
+
19
+ class Impl
20
+ def initialize(caps:)
21
+ @manifest = caps.manifest
22
+ @root = caps.root
23
+ @log_path = File.join(caps.root, "audit.log")
24
+ @audit_log = caps.audit_log
25
+ end
26
+
27
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
28
+ def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil)
29
+ check_cursor_expiry!(seq_since)
30
+
31
+ files = all_log_files
32
+ return [] if files.empty?
33
+
34
+ rows = []
35
+ files.each do |file|
36
+ File.foreach(file) do |line|
37
+ parsed = parse_row(line.chomp)
38
+ next unless parsed
39
+ next if key && parsed["key"] != key
40
+ next if role && parsed["role"] != role
41
+ next if verb && parsed["verb"] != verb
42
+ next if zone && !key_in_zone?(parsed["key"], zone)
43
+ next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
44
+ next if seq_since && (parsed["seq"].nil? || parsed["seq"] <= seq_since)
45
+ next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
46
+
47
+ rows << parsed
48
+ break if limit && rows.length >= limit
49
+ end
50
+ break if limit && rows.length >= limit
51
+ end
52
+ rows
53
+ end
54
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
+
56
+ # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
57
+ # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
58
+ def self.parse_since(str, now: Time.now.utc)
59
+ return nil if str.nil? || str.empty?
60
+ return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
61
+
62
+ m = str.match(/\A(\d+)([smhd])\z/) or return nil
63
+ mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
64
+ now - (m[1].to_i * mult)
65
+ end
66
+
67
+ private
68
+
69
+ def check_cursor_expiry!(seq_since)
70
+ return unless seq_since
71
+
72
+ log = @audit_log || Textus::Infra::AuditLog.new(@root)
73
+ min = log.min_available_seq
74
+ raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
75
+ end
76
+
77
+ def all_log_files
78
+ rotated = Dir.glob(File.join(@root, "audit.log.*"))
79
+ .reject { |p| p.end_with?(".meta.json") }
80
+ .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
81
+ active = File.exist?(@log_path) ? [@log_path] : []
82
+ rotated + active
83
+ end
84
+
85
+ def parse_row(line)
86
+ return nil if line.empty?
87
+ return nil unless line.start_with?("{")
88
+
89
+ JSON.parse(line)
90
+ rescue JSON::ParserError
91
+ nil
92
+ end
93
+
94
+ def key_in_zone?(key, zone)
95
+ mentry = @manifest.resolver.resolve(key).entry
96
+ mentry && mentry.zone == zone
97
+ rescue Textus::Error
98
+ false
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ Textus::Application::UseCase.register(:audit, Textus::Application::Read::Audit, caps: :read)
@@ -0,0 +1,91 @@
1
+ require "open3"
2
+
3
+ module Textus
4
+ module Application
5
+ module Read
6
+ # For one key, joins every audit-log row with the git commit (sha,
7
+ # author, date, subject) that introduced the file state at that audit
8
+ # row. Falls back to `git => nil` when not in a git repo or when the
9
+ # file is untracked.
10
+ module Blame
11
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
12
+ Impl.new(caps: caps).call(*, **)
13
+ end
14
+
15
+ class Impl
16
+ def initialize(caps:)
17
+ @caps = caps
18
+ @manifest = caps.manifest
19
+ @root = caps.root
20
+ end
21
+
22
+ def call(key:, limit: nil)
23
+ audit_rows = Textus::Application::Read::Audit::Impl.new(caps: @caps).call(key: key, limit: limit)
24
+ path = resolve_path(key)
25
+ return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
26
+
27
+ audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
28
+ end
29
+
30
+ private
31
+
32
+ def resolve_path(key)
33
+ res = @manifest.resolver.resolve(key)
34
+ mentry = res.entry
35
+ path = res.path
36
+ # Nested entries resolve to a file under the entry path; leaf entries
37
+ # already have a fully-resolved path. Either way `path` is what git
38
+ # needs to know about.
39
+ path || Textus::Key::Path.resolve(@manifest.data, mentry)
40
+ rescue Textus::Error
41
+ nil
42
+ end
43
+
44
+ def git_tracked?(path)
45
+ return false if path.nil?
46
+ return false unless File.exist?(path)
47
+ return false unless git_repo?
48
+
49
+ _out, _err, status = Open3.capture3(
50
+ "git", "ls-files", "--error-unmatch", path,
51
+ chdir: @root
52
+ )
53
+ status.success?
54
+ rescue Errno::ENOENT
55
+ false
56
+ end
57
+
58
+ def git_repo?
59
+ # Walk up from store root to find a .git directory.
60
+ dir = @root
61
+ loop do
62
+ return true if File.directory?(File.join(dir, ".git"))
63
+
64
+ parent = File.dirname(dir)
65
+ return false if parent == dir
66
+
67
+ dir = parent
68
+ end
69
+ end
70
+
71
+ def git_commit_at(path, timestamp:)
72
+ args = ["git", "log", "-1"]
73
+ args << "--before=#{timestamp}" if timestamp
74
+ args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
75
+ out, _err, status = Open3.capture3(*args, chdir: @root)
76
+ return nil unless status.success?
77
+
78
+ sha, author, date, subject = out.strip.split("\t", 4)
79
+ return nil if sha.nil? || sha.empty?
80
+
81
+ { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
82
+ rescue Errno::ENOENT
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ Textus::Application::UseCase.register(:blame, Textus::Application::Read::Blame, caps: :read)
@@ -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)