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,82 +0,0 @@
1
- require "open3"
2
-
3
- module Textus
4
- module Application
5
- module Reads
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
- class Blame
11
- def initialize(manifest:, root:)
12
- @manifest = manifest
13
- @root = root
14
- end
15
-
16
- def call(key:, limit: nil)
17
- audit_rows = Textus::Application::Reads::Audit.new(manifest: @manifest, root: @root).call(key: key, limit: limit)
18
- path = resolve_path(key)
19
- return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
20
-
21
- audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
22
- end
23
-
24
- private
25
-
26
- def resolve_path(key)
27
- res = @manifest.resolver.resolve(key)
28
- mentry = res.entry
29
- path = res.path
30
- # Nested entries resolve to a file under the entry path; leaf entries
31
- # already have a fully-resolved path. Either way `path` is what git
32
- # needs to know about.
33
- path || Textus::Key::Path.resolve(@manifest, mentry)
34
- rescue Textus::Error
35
- nil
36
- end
37
-
38
- def git_tracked?(path)
39
- return false if path.nil?
40
- return false unless File.exist?(path)
41
- return false unless git_repo?
42
-
43
- _out, _err, status = Open3.capture3(
44
- "git", "ls-files", "--error-unmatch", path,
45
- chdir: @root
46
- )
47
- status.success?
48
- rescue Errno::ENOENT
49
- false
50
- end
51
-
52
- def git_repo?
53
- # Walk up from store root to find a .git directory.
54
- dir = @root
55
- loop do
56
- return true if File.directory?(File.join(dir, ".git"))
57
-
58
- parent = File.dirname(dir)
59
- return false if parent == dir
60
-
61
- dir = parent
62
- end
63
- end
64
-
65
- def git_commit_at(path, timestamp:)
66
- args = ["git", "log", "-1"]
67
- args << "--before=#{timestamp}" if timestamp
68
- args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
69
- out, _err, status = Open3.capture3(*args, chdir: @root)
70
- return nil unless status.success?
71
-
72
- sha, author, date, subject = out.strip.split("\t", 4)
73
- return nil if sha.nil? || sha.empty?
74
-
75
- { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
76
- rescue Errno::ENOENT
77
- nil
78
- end
79
- end
80
- end
81
- end
82
- end
@@ -1,26 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class Deps
5
- def initialize(manifest:)
6
- @manifest = manifest
7
- end
8
-
9
- def call(key)
10
- entry = @manifest.entries.find { |e| e.key == key } or return []
11
- return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
12
-
13
- src = entry.source
14
- result = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
15
- Array(src.select).compact
16
- elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
17
- Array(src.sources).compact
18
- else
19
- []
20
- end
21
- result.uniq
22
- end
23
- end
24
- end
25
- end
26
- end
@@ -1,88 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Application
5
- module Reads
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
- class Freshness
11
- def initialize(ctx:, manifest:, file_store:, evaluator: Textus::Domain::Freshness::Evaluator)
12
- @ctx = ctx
13
- @manifest = manifest
14
- @file_store = file_store
15
- @evaluator = evaluator
16
- end
17
-
18
- def call(prefix: nil, zone: nil)
19
- rows = []
20
- @manifest.entries.each do |mentry|
21
- next if prefix && !mentry.key.start_with?(prefix)
22
- next if zone && mentry.zone != zone
23
-
24
- rows << row_for(mentry)
25
- end
26
- rows
27
- end
28
-
29
- private
30
-
31
- def row_for(mentry)
32
- set = @manifest.rules_for(mentry.key)
33
- refresh = set.refresh
34
- envelope = safe_get(mentry.key)
35
- last = envelope&.meta&.dig("last_refreshed_at")
36
-
37
- return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
38
-
39
- fp = refresh.to_freshness_policy
40
- verdict = @evaluator.call(fp, envelope, now: @ctx.now)
41
- status = if verdict.fresh? then :fresh
42
- elsif last.nil? then :never_refreshed
43
- else :stale
44
- end
45
-
46
- base_row(mentry, last).merge(
47
- ttl_seconds: fp.ttl_seconds,
48
- on_stale: fp.on_stale,
49
- status: status,
50
- next_due_at: next_due(last, fp.ttl_seconds),
51
- )
52
- end
53
-
54
- def base_row(mentry, last)
55
- {
56
- key: mentry.key,
57
- zone: mentry.zone,
58
- last_refreshed_at: last,
59
- age_seconds: last ? (@ctx.now - Time.parse(last)).to_i : nil,
60
- }
61
- end
62
-
63
- # Returns the raw envelope or nil. Nested entries (mentry.key is a
64
- # prefix, not a leaf) and missing files both resolve to nil.
65
- def safe_get(key)
66
- res = @manifest.resolver.resolve(key)
67
- return nil unless @file_store.exists?(res.path)
68
-
69
- raw = @file_store.read(res.path)
70
- parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
71
- Envelope.build(
72
- key: key, mentry: res.entry, path: res.path,
73
- meta: parsed["_meta"], body: parsed["body"],
74
- etag: Etag.for_bytes(raw), content: parsed["content"]
75
- )
76
- rescue Textus::Error
77
- nil
78
- end
79
-
80
- def next_due(last, ttl)
81
- return nil if last.nil? || ttl.nil?
82
-
83
- (Time.parse(last) + ttl).utc.iso8601
84
- end
85
- end
86
- end
87
- end
88
- end
@@ -1,67 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
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
- # `Reads::GetOrRefresh`, which composes this with the orchestrator.
9
- class Get
10
- def initialize(ctx:, manifest:, file_store:, evaluator: Textus::Domain::Freshness::Evaluator)
11
- @ctx = ctx
12
- @manifest = manifest
13
- @file_store = file_store
14
- @evaluator = evaluator
15
- end
16
-
17
- def call(key)
18
- envelope = read_raw_envelope(key)
19
- return nil if envelope.nil?
20
-
21
- policy_set = @manifest.rules_for(key)
22
- refresh_policy = policy_set.refresh
23
- return annotate_fresh(envelope) if refresh_policy.nil?
24
-
25
- policy = refresh_policy.to_freshness_policy
26
- verdict = @evaluator.call(policy, envelope, now: @ctx.now)
27
-
28
- envelope.with(freshness: Textus::Domain::Freshness.build(
29
- stale: verdict.stale?,
30
- reason: verdict.reason,
31
- refreshing: false,
32
- ))
33
- end
34
-
35
- # Strict variant: raises UnknownKey when the entry is missing.
36
- # Used by consumers (e.g. Validator) that need to distinguish absence
37
- # from emptiness.
38
- def get(key)
39
- call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
40
- end
41
-
42
- private
43
-
44
- def read_raw_envelope(key)
45
- res = @manifest.resolver.resolve(key)
46
- mentry = res.entry
47
- path = res.path
48
- return nil unless @file_store.exists?(path)
49
-
50
- raw = @file_store.read(path)
51
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
52
- Envelope.build(
53
- key: key, mentry: mentry, path: path,
54
- meta: parsed["_meta"], body: parsed["body"],
55
- etag: Etag.for_bytes(raw), content: parsed["content"]
56
- )
57
- end
58
-
59
- def annotate_fresh(envelope)
60
- envelope.with(freshness: Textus::Domain::Freshness.build(
61
- stale: false, reason: nil, refreshing: false,
62
- ))
63
- end
64
- end
65
- end
66
- end
67
- end
@@ -1,51 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- # Composes pure `Reads::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
- # `Reads::Get` directly; it has no orchestrator dependency.
12
- class GetOrRefresh
13
- def initialize(manifest:, get:, orchestrator:)
14
- @manifest = manifest
15
- @get = get
16
- @orchestrator = orchestrator
17
- end
18
-
19
- def call(key)
20
- envelope = @get.call(key)
21
- return nil if envelope.nil?
22
- return envelope unless envelope.freshness&.stale
23
-
24
- policy_set = @manifest.rules_for(key)
25
- refresh_policy = policy_set.refresh
26
- return envelope if refresh_policy.nil?
27
-
28
- policy = refresh_policy.to_freshness_policy
29
- verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
30
- action = policy.decide(verdict)
31
- outcome = @orchestrator.execute(action, key: key)
32
-
33
- case outcome
34
- when Textus::Domain::Outcome::Skipped
35
- envelope
36
- when Textus::Domain::Outcome::Refreshed
37
- outcome.envelope.with(
38
- freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
39
- )
40
- when Textus::Domain::Outcome::Detached
41
- envelope.with(freshness: envelope.freshness.with(refreshing: true))
42
- when Textus::Domain::Outcome::Failed
43
- envelope.with(
44
- freshness: envelope.freshness.with(refresh_error: outcome.error.message),
45
- )
46
- end
47
- end
48
- end
49
- end
50
- end
51
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class List
5
- def initialize(manifest:)
6
- @manifest = manifest
7
- end
8
-
9
- def call(prefix: nil, zone: nil)
10
- rows = @manifest.resolver.enumerate(prefix: prefix)
11
- rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
12
- rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,39 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- # For one key, surface every matching policy block along with the
5
- # per-slot effective value (which loses ties win-by-specificity).
6
- class PolicyExplain
7
- def initialize(manifest:)
8
- @manifest = manifest
9
- end
10
-
11
- def call(key:)
12
- policies = @manifest.rules
13
- matching = policies.explain(key)
14
- winners = policies.for(key)
15
-
16
- {
17
- key: key,
18
- matched_blocks: matching.map do |b|
19
- {
20
- match: b.match,
21
- refresh: !b.refresh.nil?,
22
- handler_allowlist: !b.handler_allowlist.nil?,
23
- promote: !b.promote.nil?,
24
- }
25
- end,
26
- effective: {
27
- refresh: winners.refresh && {
28
- ttl_seconds: winners.refresh.ttl_seconds,
29
- on_stale: winners.refresh.on_stale,
30
- },
31
- handler_allowlist: winners.handler_allowlist&.handlers,
32
- promotion: winners.promote && { requires: winners.promote.requires },
33
- },
34
- }
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class Published
5
- def initialize(manifest:)
6
- @manifest = manifest
7
- end
8
-
9
- def call
10
- @manifest.entries.reject { |e| e.publish_to.empty? }.map do |e|
11
- { "key" => e.key, "publish_to" => e.publish_to }
12
- end
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,63 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- # Aggregator over audit + freshness + review + doctor. One round-trip
5
- # for an agent's per-turn heartbeat. All component reads are existing
6
- # APIs; pulse is sugar with a stable envelope shape and a monotonic
7
- # cursor (seq).
8
- class Pulse
9
- def initialize(ctx:, manifest:, file_store:, audit_log:, root:, store:)
10
- @ctx = ctx
11
- @manifest = manifest
12
- @file_store = file_store
13
- @audit_log = audit_log
14
- @root = root
15
- @store = store
16
- end
17
-
18
- def call(since: 0)
19
- changed = audit_changes_since(since)
20
- {
21
- "cursor" => @audit_log.latest_seq,
22
- "changed" => changed,
23
- "stale" => stale_keys,
24
- "pending_review" => review_keys,
25
- "doctor" => doctor_summary,
26
- }
27
- end
28
-
29
- private
30
-
31
- def audit_changes_since(seq)
32
- Reads::Audit.new(manifest: @manifest, root: @root, audit_log: @audit_log)
33
- .call(seq_since: seq)
34
- end
35
-
36
- def stale_keys
37
- # Freshness rows use symbol keys: { key: "x.y", status: :stale, ... }
38
- rows = Reads::Freshness.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call
39
- rows.select { |r| r[:status] == :stale }.map { |r| r[:key] }
40
- end
41
-
42
- def review_keys
43
- # List constructor takes only manifest:; returns hashes with string keys.
44
- # Guard: zones is a Hash keyed by name string.
45
- return [] unless @manifest.zones.key?("review")
46
-
47
- rows = Reads::List.new(manifest: @manifest).call(zone: "review")
48
- rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
49
- end
50
-
51
- def doctor_summary
52
- result = Textus::Doctor.run(@store)
53
- issues = result["issues"] || []
54
- {
55
- "ok" => result["ok"],
56
- "warn" => issues.count { |i| i["level"] == "warning" },
57
- "fail" => issues.count { |i| i["level"] == "error" },
58
- }
59
- end
60
- end
61
- end
62
- end
63
- end
@@ -1,27 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class Rdeps
5
- def initialize(manifest:)
6
- @manifest = manifest
7
- end
8
-
9
- def call(key)
10
- @manifest.entries.each_with_object([]) do |e, acc|
11
- next unless e.is_a?(Textus::Manifest::Entry::Derived)
12
-
13
- src = e.source
14
- sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
15
- Array(src.select).compact
16
- elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
17
- Array(src.sources).compact
18
- else
19
- []
20
- end
21
- acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
22
- end
23
- end
24
- end
25
- end
26
- end
27
- end
@@ -1,18 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class SchemaEnvelope
5
- def initialize(manifest:, schemas:)
6
- @manifest = manifest
7
- @schemas = schemas
8
- end
9
-
10
- def call(key)
11
- mentry = @manifest.resolver.resolve(key).entry
12
- schema = @schemas.fetch_or_nil(mentry.schema)
13
- { "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
14
- end
15
- end
16
- end
17
- end
18
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class Stale
5
- def initialize(manifest:)
6
- @manifest = manifest
7
- end
8
-
9
- def call(prefix: nil, zone: nil)
10
- Textus::Domain::Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
11
- end
12
- end
13
- end
14
- end
15
- end
@@ -1,23 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class Uid
5
- def initialize(ctx:, manifest:, file_store:)
6
- @ctx = ctx
7
- @manifest = manifest
8
- @file_store = file_store
9
- end
10
-
11
- def call(key)
12
- get.get(key).uid
13
- end
14
-
15
- private
16
-
17
- def get
18
- @get ||= Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store)
19
- end
20
- end
21
- end
22
- end
23
- end
@@ -1,24 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class ValidateAll
5
- def initialize(ctx:, manifest:, file_store:, schemas:, audit_log:)
6
- @ctx = ctx
7
- @manifest = manifest
8
- @file_store = file_store
9
- @schemas = schemas
10
- @audit_log = audit_log
11
- end
12
-
13
- def call
14
- Validator.new(
15
- reader: Get.new(ctx: @ctx, manifest: @manifest, file_store: @file_store),
16
- manifest: @manifest,
17
- audit_log: @audit_log,
18
- schema_for: ->(name) { @schemas.fetch_or_nil(name) },
19
- ).call
20
- end
21
- end
22
- end
23
- end
24
- end
@@ -1,86 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class Validator
5
- def initialize(reader:, manifest:, audit_log:, schema_for:)
6
- @reader = reader
7
- @manifest = manifest
8
- @audit_log = audit_log
9
- @schema_for = schema_for
10
- end
11
-
12
- def call
13
- violations = []
14
- check_content_violations(violations)
15
- check_role_authority_violations(violations)
16
- { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
17
- end
18
-
19
- private
20
-
21
- def check_content_violations(violations)
22
- @manifest.resolver.enumerate.each do |row|
23
- key = row[:key]
24
- mentry = row[:manifest_entry]
25
- env = fetch_envelope(key, violations) or next
26
- schema = mentry.schema && @schema_for.call(mentry.schema)
27
- next unless schema
28
-
29
- begin
30
- validate_schema!(schema, env, mentry.format)
31
- rescue Textus::Error => e
32
- violations << { "key" => key, "code" => e.code, "message" => e.message }
33
- end
34
- end
35
- end
36
-
37
- def check_role_authority_violations(violations)
38
- @manifest.resolver.enumerate.each do |row|
39
- mentry = row[:manifest_entry]
40
- next unless mentry.schema
41
-
42
- schema = @schema_for.call(mentry.schema)
43
- next unless schema
44
-
45
- env = begin
46
- @reader.get(row[:key])
47
- rescue StandardError
48
- next
49
- end
50
- append_authority_violations(violations, row[:key], env, schema)
51
- end
52
- end
53
-
54
- def append_authority_violations(violations, key, env, schema)
55
- last_writer = @audit_log.last_writer_for(key)
56
- return if last_writer.nil?
57
-
58
- last_writer_is_authority = @manifest.role_kind(last_writer) == :accept_authority
59
-
60
- env.meta.each_key do |field|
61
- owner = schema.maintained_by(field)
62
- next if owner.nil? || last_writer == owner || last_writer_is_authority
63
-
64
- violations << { "key" => key, "code" => "role_authority",
65
- "field" => field, "expected" => owner, "last_writer" => last_writer }
66
- end
67
- end
68
-
69
- def fetch_envelope(key, violations)
70
- @reader.get(key)
71
- rescue Textus::Error => e
72
- violations << { "key" => key, "code" => e.code, "message" => e.message }
73
- nil
74
- end
75
-
76
- def validate_schema!(schema, envelope, format)
77
- payload = case format
78
- when "json", "yaml" then envelope.content || {}
79
- else envelope.meta || {}
80
- end
81
- schema.validate!(payload)
82
- end
83
- end
84
- end
85
- end
86
- end
@@ -1,18 +0,0 @@
1
- module Textus
2
- module Application
3
- module Reads
4
- class Where
5
- def initialize(manifest:)
6
- @manifest = manifest
7
- end
8
-
9
- def call(key)
10
- res = @manifest.resolver.resolve(key)
11
- mentry = res.entry
12
- path = res.path
13
- { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
14
- end
15
- end
16
- end
17
- end
18
- end