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
@@ -4,11 +4,11 @@ module Textus
4
4
  class Templates < Check
5
5
  def call
6
6
  out = []
7
- store.manifest.entries.each do |entry|
7
+ manifest.data.entries.each do |entry|
8
8
  template = entry.respond_to?(:template) ? entry.template : nil
9
9
  next if template.nil?
10
10
 
11
- tp = File.join(store.root, "templates", template)
11
+ tp = File.join(root, "templates", template)
12
12
  next if File.exist?(tp)
13
13
 
14
14
  out << {
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class UnownedSchemaFields < Check
5
5
  def call
6
- dir = File.join(store.root, "schemas")
6
+ dir = File.join(root, "schemas")
7
7
  return [] unless File.directory?(dir)
8
8
 
9
9
  Dir.glob(File.join(dir, "*.yaml")).flat_map do |path|
@@ -14,8 +14,8 @@ module Textus
14
14
  .downcase
15
15
  end
16
16
 
17
- def initialize(store)
18
- @store = store
17
+ def initialize(container)
18
+ @container = container
19
19
  end
20
20
 
21
21
  def call
@@ -24,7 +24,16 @@ module Textus
24
24
 
25
25
  protected
26
26
 
27
- attr_reader :store
27
+ def root = @container.root
28
+ def manifest = @container.manifest
29
+ def rpc = @container.rpc
30
+
31
+ # Dispatch a verb through the static Dispatcher table.
32
+ def dispatch(verb, *, **)
33
+ klass = Textus::Dispatcher.fetch(verb)
34
+ call_value = Textus::Call.build(role: Textus::Role::DEFAULT)
35
+ klass.new(container: @container, call: call_value).call(*, **)
36
+ end
28
37
  end
29
38
  end
30
39
  end
data/lib/textus/doctor.rb CHANGED
@@ -30,7 +30,7 @@ module Textus
30
30
 
31
31
  module_function
32
32
 
33
- def run(store, checks: nil)
33
+ def build(container:, checks: nil)
34
34
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
35
35
  unknown = selected_keys - ALL_CHECKS
36
36
  unless unknown.empty?
@@ -40,8 +40,8 @@ module Textus
40
40
  end
41
41
 
42
42
  selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
43
- issues = selected.flat_map { |c| c.new(store).call }
44
- issues.concat(run_registered_checks(store))
43
+ issues = selected.flat_map { |c| c.new(container).call }
44
+ issues.concat(run_registered_checks(container))
45
45
 
46
46
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
47
47
  {
@@ -52,30 +52,27 @@ module Textus
52
52
  }
53
53
  end
54
54
 
55
- def run_registered_checks(store)
56
- out = []
57
- store.bus.rpc_names(:validate).each do |name|
58
- callable = store.bus.rpc_callable(:validate, name)
59
- begin
60
- result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: store) }
61
- if result.is_a?(Array)
62
- out.concat(result.map { |h| h.transform_keys(&:to_s) })
63
- else
64
- out << fail_issue(name, code: "doctor_check.bad_return",
65
- message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
66
- fix: "return an array of issue hashes from the doctor_check block")
67
- end
68
- rescue Timeout::Error
69
- out << fail_issue(name, code: "doctor_check.timeout",
70
- message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
71
- fix: "shorten the check or split it into smaller checks")
72
- rescue StandardError => e
73
- out << fail_issue(name, code: "doctor_check.failed",
74
- message: "#{e.class}: #{e.message}",
75
- fix: "fix the :validate hook in .textus/hooks/")
76
- end
55
+ def run_registered_checks(container)
56
+ container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
57
+ end
58
+
59
+ def invoke_registered_check(container, name)
60
+ result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
61
+ container.rpc.invoke(:validate, name, caps: container)
77
62
  end
78
- out
63
+ return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
64
+
65
+ [fail_issue(name, code: "doctor_check.bad_return",
66
+ message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
67
+ fix: "return an array of issue hashes from the doctor_check block")]
68
+ rescue Timeout::Error
69
+ [fail_issue(name, code: "doctor_check.timeout",
70
+ message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
71
+ fix: "shorten the check or split it into smaller checks")]
72
+ rescue StandardError => e
73
+ [fail_issue(name, code: "doctor_check.failed",
74
+ message: "#{e.class}: #{e.message}",
75
+ fix: "fix the :validate hook in .textus/hooks/")]
79
76
  end
80
77
 
81
78
  def fail_issue(name, code:, message:, fix:)
@@ -88,6 +85,6 @@ module Textus
88
85
  }
89
86
  end
90
87
 
91
- private_class_method :run_registered_checks, :fail_issue
88
+ private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
92
89
  end
93
90
  end
@@ -3,32 +3,32 @@
3
3
  module Textus
4
4
  module Domain
5
5
  # Authorization service. Single source of truth for "given a manifest
6
- # entry and a role, may this caller read/write?". Extracted from
7
- # Application::Context so the rule lives in Domain alongside Permission.
6
+ # entry and a role, may this caller read/write?". Lives in Domain
7
+ # alongside Permission.
8
8
  class Authorizer
9
9
  def initialize(manifest:)
10
10
  @manifest = manifest
11
11
  end
12
12
 
13
13
  def can_write?(zone, role:)
14
- @manifest.permission_for(zone.to_s).allows_write?(role)
14
+ @manifest.policy.permission_for(zone.to_s).allows_write?(role)
15
15
  end
16
16
 
17
17
  def can_read?(zone, role:)
18
- @manifest.permission_for(zone.to_s).allows_read?(role)
18
+ @manifest.policy.permission_for(zone.to_s).allows_read?(role)
19
19
  end
20
20
 
21
21
  def authorize_write!(mentry, role:)
22
22
  return if can_write?(mentry.zone, role: role)
23
23
 
24
- writers = @manifest.zone_writers(mentry.zone)
24
+ writers = @manifest.policy.zone_writers(mentry.zone)
25
25
  raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
26
26
  end
27
27
 
28
28
  def authorize_read!(mentry, role:)
29
29
  return if can_read?(mentry.zone, role: role)
30
30
 
31
- readers = @manifest.zone_readers[mentry.zone]
31
+ readers = @manifest.policy.zone_readers[mentry.zone]
32
32
  readers = nil if readers == :all
33
33
  raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
34
34
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Application
2
+ module Domain
3
3
  module Policy
4
4
  module Predicates
5
5
  # Promotion predicate: the role driving the promotion must have
@@ -20,7 +20,7 @@ module Textus
20
20
  role_str = role&.to_s
21
21
  return true if role_str.nil? || role_str.empty?
22
22
 
23
- kind = manifest.role_kind(role_str)
23
+ kind = manifest.policy.role_kind(role_str)
24
24
  return true if kind == :accept_authority
25
25
 
26
26
  @reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Application
2
+ module Domain
3
3
  module Policy
4
4
  module Predicates
5
5
  class SchemaValid
@@ -2,7 +2,7 @@ require_relative "predicates/schema_valid"
2
2
  require_relative "predicates/accept_authority_signed"
3
3
 
4
4
  module Textus
5
- module Application
5
+ module Domain
6
6
  module Policy
7
7
  class Promotion
8
8
  Result = Struct.new(:ok?, :reasons, keyword_init: true)
@@ -1,69 +1,15 @@
1
- require "json"
2
1
  require "digest"
3
- require "fileutils"
4
2
 
5
3
  module Textus
6
4
  module Domain
7
- # Value object for sentinel files written by Infra::Publisher and inspected
8
- # by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
9
- # sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
10
- # <target-rel-to-repo>.textus-managed.json). Target/source are repo-relative
11
- # when the published file is under the repo root, absolute otherwise.
5
+ # Pure value object representing a published-file sentinel. Holds the
6
+ # recorded target path, source path, sha256 checksum, and publish mode.
7
+ # Has no filesystem I/O path layout and persistence live in
8
+ # Ports::SentinelStore; predicate methods accept a FileStat port for
9
+ # existence and content checks.
12
10
  class Sentinel
13
- SUFFIX = ".textus-managed.json".freeze
14
- DIR = "sentinels".freeze
15
-
16
11
  attr_reader :target, :source, :sha256, :mode
17
12
 
18
- def self.write!(target:, source:, store_root:)
19
- path = sentinel_path(target, store_root)
20
- FileUtils.mkdir_p(File.dirname(path))
21
- repo_root = File.dirname(store_root)
22
- File.write(path, JSON.generate(
23
- "source" => rel_or_abs(source, repo_root),
24
- "target" => rel_or_abs(target, repo_root),
25
- "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
26
- "mode" => "copy",
27
- ))
28
- end
29
-
30
- def self.load(path, repo_root)
31
- raw = JSON.parse(File.read(path))
32
- new(
33
- target: absolutize(raw["target"], repo_root),
34
- source: absolutize(raw["source"], repo_root),
35
- sha256: raw["sha256"],
36
- mode: raw["mode"],
37
- )
38
- rescue JSON::ParserError, Errno::ENOENT
39
- nil
40
- end
41
-
42
- def self.sentinel_path(target, store_root)
43
- repo_root = File.dirname(store_root)
44
- rel = relative_to(target, repo_root) || File.basename(target)
45
- File.join(store_root, DIR, rel + SUFFIX)
46
- end
47
-
48
- def self.rel_or_abs(path, repo_root)
49
- relative_to(path, repo_root) || File.expand_path(path)
50
- end
51
-
52
- def self.relative_to(path, repo_root)
53
- path = File.expand_path(path)
54
- base = File.expand_path(repo_root)
55
- return nil unless path.start_with?(base + File::SEPARATOR)
56
-
57
- path[(base.length + 1)..]
58
- end
59
-
60
- def self.absolutize(path, repo_root)
61
- return path if path.nil?
62
- return path if File.absolute_path?(path)
63
-
64
- File.expand_path(path, repo_root)
65
- end
66
-
67
13
  def initialize(target:, source:, sha256:, mode:)
68
14
  @target = target
69
15
  @source = source
@@ -71,15 +17,13 @@ module Textus
71
17
  @mode = mode
72
18
  end
73
19
 
74
- def orphan?
75
- @target.nil? || !File.exist?(@target)
76
- end
20
+ def orphan?(file_stat) = @target.nil? || !file_stat.exists?(@target)
77
21
 
78
- def drift?
79
- return false if orphan?
22
+ def drift?(file_stat)
23
+ return false if orphan?(file_stat)
80
24
  return false if @sha256.nil?
81
25
 
82
- Digest::SHA256.hexdigest(File.binread(@target)) != @sha256
26
+ Digest::SHA256.hexdigest(file_stat.read(@target)) != @sha256
83
27
  end
84
28
  end
85
29
  end
@@ -8,34 +8,43 @@ module Textus
8
8
  # entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
9
9
  # (possibly empty) per entry.
10
10
  class GeneratorCheck
11
- def initialize(manifest:)
12
- @manifest = manifest
11
+ def initialize(manifest:, file_stat:)
12
+ @manifest = manifest
13
+ @file_stat = file_stat
13
14
  end
14
15
 
15
16
  def rows_for(mentry)
16
- return [] unless mentry.in_generator_zone?
17
- return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
17
+ return [] unless applicable?(mentry)
18
18
 
19
- src = mentry.source
20
- return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
19
+ path = Textus::Key::Path.resolve(@manifest.data, mentry)
20
+ reason = stale_reason(mentry, path)
21
+ reason ? [stale_row(mentry, path, reason)] : []
22
+ end
21
23
 
22
- path = Textus::Key::Path.resolve(@manifest, mentry)
23
- return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
24
+ private
24
25
 
25
- parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
26
- generated_at = parsed["_meta"].dig("generated", "at")
27
- return [stale_row(mentry, path, "missing generated.at frontmatter")] unless generated_at
26
+ def applicable?(mentry)
27
+ mentry.in_generator_zone?(@manifest.policy) &&
28
+ mentry.is_a?(Textus::Manifest::Entry::Derived) &&
29
+ mentry.source.is_a?(Textus::Manifest::Entry::Derived::External)
30
+ end
28
31
 
29
- gen_time = parse_time(generated_at)
30
- return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
32
+ def stale_reason(mentry, path)
33
+ return "derived entry has never been generated" unless @file_stat.exists?(path)
31
34
 
32
- offender = newest_source_after(src, gen_time)
33
- return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
35
+ generated_at = generated_at_of(mentry, path)
36
+ return "missing generated.at frontmatter" unless generated_at
34
37
 
35
- []
38
+ gen_time = parse_time(generated_at)
39
+ return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
40
+
41
+ offender = newest_source_after(mentry.source, gen_time)
42
+ "source '#{offender}' modified after generated.at" if offender
36
43
  end
37
44
 
38
- private
45
+ def generated_at_of(mentry, path)
46
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
47
+ end
39
48
 
40
49
  def parse_time(str)
41
50
  Time.parse(str.to_s)
@@ -54,7 +63,7 @@ module Textus
54
63
  def check_source(src, gen_time)
55
64
  if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
56
65
  @manifest.resolver.enumerate(prefix: src).each do |row|
57
- return src if File.mtime(row[:path]) > gen_time
66
+ return src if @file_stat.mtime(row[:path]) > gen_time
58
67
  end
59
68
  nil
60
69
  else
@@ -63,18 +72,29 @@ module Textus
63
72
  end
64
73
 
65
74
  def check_filesystem_source(src, gen_time)
66
- abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
67
- if File.directory?(abs)
68
- Dir.glob(File.join(abs, "**", "*")).each do |fp|
69
- next unless File.file?(fp)
70
- return src if File.mtime(fp) > gen_time
71
- end
72
- nil
73
- elsif File.exist?(abs) && File.mtime(abs) > gen_time
75
+ abs = absolutize_source(src)
76
+ if @file_stat.directory?(abs)
77
+ dir_has_newer_file?(abs, gen_time) ? src : nil
78
+ elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
74
79
  src
75
80
  end
76
81
  end
77
82
 
83
+ def absolutize_source(src)
84
+ File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
85
+ end
86
+
87
+ def dir_has_newer_file?(abs, gen_time)
88
+ @file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
89
+ file?(fpath) && @file_stat.mtime(fpath) > gen_time
90
+ end
91
+ end
92
+
93
+ # FileStat substitute for File.file?: excludes directories but treats
94
+ # special files (FIFOs/sockets/devices) as regular files — acceptable
95
+ # because a generator source tree won't contain them.
96
+ def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
97
+
78
98
  def stale_row(mentry, path, reason)
79
99
  {
80
100
  "key" => mentry.key,
@@ -6,30 +6,38 @@ module Textus
6
6
  # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
7
  # Array of row hashes (possibly empty) per entry.
8
8
  class IntakeCheck
9
- def initialize(manifest:)
10
- @manifest = manifest
9
+ def initialize(manifest:, file_stat:, clock:)
10
+ @manifest = manifest
11
+ @file_stat = file_stat
12
+ @clock = clock
11
13
  end
12
14
 
13
15
  def rows_for(mentry)
14
16
  return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
15
17
 
16
- ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
18
+ ttl = @manifest.rules.for(mentry.key).refresh&.ttl_seconds
17
19
  return [] unless ttl
18
20
 
19
- path = Textus::Key::Path.resolve(@manifest, mentry)
20
- return [row(mentry, path, "never refreshed")] unless File.exist?(path)
21
+ path = Textus::Key::Path.resolve(@manifest.data, mentry)
22
+ reason = ttl_reason(mentry, path, ttl)
23
+ reason ? [row(mentry, path, reason)] : []
24
+ end
25
+
26
+ private
21
27
 
22
- meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
23
- last_str = meta["last_refreshed_at"]
24
- return [row(mentry, path, "never refreshed (no last_refreshed_at)")] if last_str.nil?
28
+ def ttl_reason(mentry, path, ttl)
29
+ return "never refreshed" unless @file_stat.exists?(path)
25
30
 
26
- last = parse_time(last_str)
27
- return [row(mentry, path, "ttl exceeded (#{ttl}s)")] if last.nil? || (Time.now - last) > ttl
31
+ last_str = last_refreshed_of(mentry, path)
32
+ return "never refreshed (no last_refreshed_at)" if last_str.nil?
28
33
 
29
- []
34
+ last = parse_time(last_str)
35
+ "ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
30
36
  end
31
37
 
32
- private
38
+ def last_refreshed_of(mentry, path)
39
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_refreshed_at"]
40
+ end
33
41
 
34
42
  def parse_time(str)
35
43
  Time.parse(str.to_s)
@@ -1,14 +1,14 @@
1
1
  module Textus
2
2
  module Domain
3
3
  class Staleness
4
- def initialize(manifest:)
4
+ def initialize(manifest:, file_stat:, clock:)
5
5
  @manifest = manifest
6
- @generator_check = GeneratorCheck.new(manifest: manifest)
7
- @intake_check = IntakeCheck.new(manifest: manifest)
6
+ @generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
7
+ @intake_check = IntakeCheck.new(manifest: manifest, file_stat: file_stat, clock: clock)
8
8
  end
9
9
 
10
10
  def call(prefix: nil, zone: nil)
11
- @manifest.entries
11
+ @manifest.data.entries
12
12
  .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
13
13
  .flat_map { |m| @generator_check.rows_for(m) + @intake_check.rows_for(m) }
14
14
  end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ class Envelope
3
+ module IO
4
+ # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
+ # bytes, parses them via the format strategy, and hands back an
6
+ # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
7
+ # (existing-uid lookup for the uid-preservation step in #put).
8
+ #
9
+ # No audit, no events, no permission checks — those live one layer up.
10
+ class Reader
11
+ def initialize(file_store:, manifest:)
12
+ @file_store = file_store
13
+ @manifest = manifest
14
+ end
15
+
16
+ def read(key)
17
+ res = @manifest.resolver.resolve(key)
18
+ path = res.path
19
+ return nil unless @file_store.exists?(path)
20
+
21
+ mentry = res.entry
22
+ raw = @file_store.read(path)
23
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
24
+ Textus::Envelope.build(
25
+ key: key, mentry: mentry, path: path,
26
+ meta: parsed["_meta"], body: parsed["body"],
27
+ etag: Etag.for_bytes(raw), content: parsed["content"]
28
+ )
29
+ end
30
+
31
+ def existing_uid(key)
32
+ env = read(key)
33
+ env&.uid
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def exists?(key)
39
+ @file_store.exists?(@manifest.resolver.resolve(key).path)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end