textus 0.52.0 → 0.54.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 (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -1,18 +1,14 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  module Freshness
6
6
  # The single currency evaluator (ADR 0099). Answers "is the stored data
7
- # stale relative to its source?" for every produce-method:
8
- # - intake (source.from: handler) -> AGE signal: now - basis > source.ttl,
9
- # basis = _meta.last_fetched_at (else file mtime). No ttl -> :no_policy
10
- # (skipped a cadence-less handler is not auto-re-pulled).
11
- # - derived/external -> DRIFT signal: a source changed since generated.at
12
- # (surfaced by the doctor generator_drift check; derived entries annotate
13
- # fresh at read time because converge runs them reactively).
14
- # Replaces Domain::IntakeStaleness and Domain::Staleness::GeneratorCheck and
15
- # the inline copies in Read::Get / Read::Freshness.
7
+ # stale relative to its retention rule?" and detects generator drift for
8
+ # external entries.
9
+ # - retention rule TTL -> AGE signal: now - file_basis > ttl_seconds
10
+ # - external -> DRIFT signal: a source changed since generated.at
11
+ # (surfaced by the doctor generator_drift check).
16
12
  class Evaluator
17
13
  def initialize(manifest:, file_stat:, clock:)
18
14
  @manifest = manifest
@@ -20,32 +16,28 @@ module Textus
20
16
  @clock = clock
21
17
  end
22
18
 
23
- # Per-entry currency Verdict (drives Read::Get's annotation). Non-intake
24
- # entries are always fresh (retention is GC, not content currency).
19
+ # Per-entry currency Verdict driven by the retention rule TTL (if any).
25
20
  def verdict(mentry)
26
- return fresh unless mentry.intake?
27
-
28
- ttl = mentry.source.ttl_seconds
21
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
29
22
  return fresh if ttl.nil?
30
23
 
31
- stale = age_stale?(intake_basis(mentry), ttl)
24
+ stale = age_stale?(file_basis(mentry), ttl)
32
25
  Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
33
26
  end
34
27
 
35
- # Keys of intake entries past their source.ttl — the converge produce
36
- # scope (replaces Domain::IntakeStaleness#call). A ttl-less intake entry
37
- # is :no_policy and skipped; a never-recorded one (with a ttl) is stale.
38
- def stale_intake_keys(prefix: nil, zone: nil)
39
- @manifest.data.entries.select { |m| due?(m, prefix: prefix, zone: zone) }.map(&:key)
28
+ # Keys of entries past their retention rule TTL — the refresh produce scope.
29
+ def stale_keys(prefix: nil, lane: nil)
30
+ @manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
40
31
  end
41
32
 
42
- # Age basis as a Time (or nil): _meta.last_fetched_at when present, else
43
- # file mtime. The single definition the three call sites used to repeat.
44
- def intake_basis(mentry)
33
+ alias stale_intake_keys stale_keys
34
+
35
+ # File basis as a Time (or nil): file mtime when present, else nil.
36
+ def file_basis(mentry)
45
37
  path = @manifest.resolver.resolve(mentry.key).path
46
38
  return nil unless @file_stat.exists?(path)
47
39
 
48
- last_fetched_at(mentry, path) || @file_stat.mtime(path)
40
+ @file_stat.mtime(path)
49
41
  end
50
42
 
51
43
  # Generator-drift rows for one entry (replaces Staleness::GeneratorCheck#
@@ -62,18 +54,17 @@ module Textus
62
54
 
63
55
  def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
64
56
 
65
- def due?(mentry, prefix:, zone:)
66
- return false unless mentry.intake?
67
- return false if zone && mentry.zone != zone
57
+ def due?(mentry, prefix:, lane:)
58
+ return false if lane && mentry.lane != lane
68
59
  return false if prefix && !mentry.key.start_with?(prefix)
69
60
 
70
- ttl = mentry.source.ttl_seconds
71
- return false if ttl.nil? # no declared cadence -> :no_policy, skip (ADR 0099)
61
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
62
+ return false if ttl.nil?
72
63
 
73
64
  path = @manifest.resolver.resolve(mentry.key).path
74
65
  return true unless @file_stat.exists?(path)
75
66
 
76
- age_stale?(intake_basis(mentry), ttl)
67
+ age_stale?(file_basis(mentry), ttl)
77
68
  end
78
69
 
79
70
  # The one age comparison. A never-recorded entry (nil basis) is stale.
@@ -83,16 +74,9 @@ module Textus
83
74
  (@clock.now - basis).to_i > ttl
84
75
  end
85
76
 
86
- def last_fetched_at(mentry, path)
87
- meta = Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]
88
- Time.parse(meta["last_fetched_at"].to_s) if meta && meta["last_fetched_at"]
89
- rescue StandardError
90
- nil
91
- end
92
-
93
77
  # --- generator drift (lifted from Staleness::GeneratorCheck) ---
94
78
 
95
- def drift_applicable?(mentry) = mentry.derived? && mentry.external?
79
+ def drift_applicable?(mentry) = mentry.external?
96
80
 
97
81
  def drift_reason(mentry, path)
98
82
  return "derived entry has never been generated" unless @file_stat.exists?(path)
@@ -108,7 +92,7 @@ module Textus
108
92
  end
109
93
 
110
94
  def generated_at_of(mentry, path)
111
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
95
+ Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
112
96
  end
113
97
 
114
98
  def parse_time(str)
@@ -158,7 +142,7 @@ module Textus
158
142
  def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
159
143
 
160
144
  def drift_row(mentry, path, reason)
161
- { "key" => mentry.key, "path" => path, "generator" => mentry.raw["compute"], "reason" => reason }
145
+ { "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
162
146
  end
163
147
  end
164
148
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  module Freshness
6
6
  # Value object describing the freshness annotation attached to an Envelope
7
- # after a currency evaluation (ADR 0099 — was Domain::Freshness).
7
+ # after a currency evaluation (ADR 0099 — was Core::Freshness).
8
8
  #
9
9
  # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
10
  # full field set. It emits the legacy keys ("stale", "stale_reason",
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  # Currency — "is the stored data stale relative to its source?" (ADR 0099).
6
6
  # The home of the single Freshness evaluator and its Verdict value object.
7
- # Distinct from Domain::Retention (GC dueness, Q2).
7
+ # Distinct from Core::Retention (GC dueness, Q2).
8
8
  module Freshness
9
9
  end
10
10
  end
@@ -1,12 +1,12 @@
1
1
  require "time"
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  module Retention
6
6
  # Retention sweep reporter (ADR 0093/0099). Which entries are past their
7
7
  # `retention:` ttl and the destructive action that applies. Age basis: file
8
- # mtime. Only drop/archive. Renamed off the Domain::Retention vs
9
- # Domain::Policy::Retention collision (ADR 0099).
8
+ # mtime. Only drop/archive. Renamed off the Core::Retention vs
9
+ # Manifest::Policy::Retention collision (ADR 0099).
10
10
  class Sweep
11
11
  def self.expired?(ttl_seconds:, mtime:, now:)
12
12
  return false if ttl_seconds.nil? || mtime.nil?
@@ -20,16 +20,16 @@ module Textus
20
20
  @clock = clock
21
21
  end
22
22
 
23
- def call(prefix: nil, zone: nil)
23
+ def call(prefix: nil, lane: nil)
24
24
  @manifest.data.entries
25
- .select { |m| matches?(m, prefix: prefix, zone: zone) }
25
+ .select { |m| matches?(m, prefix: prefix, lane: lane) }
26
26
  .flat_map { |m| rows_for(m) }
27
27
  end
28
28
 
29
29
  private
30
30
 
31
- def matches?(mentry, prefix:, zone:)
32
- return false if zone && mentry.zone != zone
31
+ def matches?(mentry, prefix:, lane:)
32
+ return false if lane && mentry.lane != lane
33
33
  return false if prefix && !Textus::Key::Matching.matches_prefix?(
34
34
  mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
35
35
  )
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  # Retention — "is the entry old enough to retire?" (Q2, ADR 0093/0099).
6
6
  # GC dueness, orthogonal to Freshness (content currency). The reporter is
7
- # Domain::Retention::Sweep; the manifest rule policy is Domain::Policy::Retention.
7
+ # Core::Retention::Sweep; the manifest rule policy is Manifest::Policy::Retention.
8
8
  module Retention
9
9
  end
10
10
  end
@@ -1,7 +1,7 @@
1
1
  require "digest"
2
2
 
3
3
  module Textus
4
- module Domain
4
+ module Core
5
5
  # Pure value object representing a published-file sentinel. Holds the
6
6
  # recorded target path, source path, sha256 checksum, and publish mode.
7
7
  # Has no filesystem I/O — path layout and persistence live in
@@ -8,7 +8,7 @@ module Textus
8
8
  # verb reported.
9
9
  class GeneratorDrift < Check
10
10
  def call
11
- gen = Textus::Domain::Freshness::Evaluator.new(
11
+ gen = Textus::Core::Freshness::Evaluator.new(
12
12
  manifest: manifest,
13
13
  file_stat: Textus::Ports::Storage::FileStat.new,
14
14
  clock: Textus::Ports::Clock.new,
@@ -8,7 +8,7 @@ module Textus
8
8
  next unless entry.nested?
9
9
  next if entry.publish_mode.keyless? # publish_tree files are opaque payload, never keys (ADR 0047)
10
10
 
11
- base = File.join(root, "zones", entry.path)
11
+ base = File.join(root, Textus::Key::Path.normalize_relative_path(entry.path))
12
12
  next unless File.directory?(base)
13
13
 
14
14
  check_all_paths(entry, base, out)
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class NotebookSources < Check
5
+ def call
6
+ issues = []
7
+ manifest.resolver.enumerate.each do |row|
8
+ next unless row[:key].start_with?("notebook.notes.")
9
+ next unless row[:path] && File.exist?(row[:path])
10
+
11
+ sources = parse_sources(row[:path])
12
+ sources.each do |raw_key|
13
+ next if raw_entry_exists?(raw_key)
14
+
15
+ issues << {
16
+ "code" => "notebook.source_missing",
17
+ "level" => "warning",
18
+ "subject" => row[:key],
19
+ "message" => "notebook entry '#{row[:key]}' references raw key '#{raw_key}' " \
20
+ "which does not exist in the store",
21
+ "fix" => "re-ingest the source: textus ingest ..., or remove the stale sources: entry",
22
+ }
23
+ end
24
+ end
25
+ issues
26
+ end
27
+
28
+ private
29
+
30
+ def parse_sources(path)
31
+ content = File.read(path)
32
+ match = content.match(/\A---\n(.*?)\n---/m)
33
+ return [] unless match
34
+
35
+ front = YAML.safe_load(match[1])
36
+ Array(front&.dig("sources"))
37
+ rescue StandardError
38
+ []
39
+ end
40
+
41
+ def raw_entry_exists?(key)
42
+ path = manifest.resolver.resolve(key).path
43
+ File.exist?(path)
44
+ rescue Textus::UnknownKey, Textus::Error
45
+ false
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -8,10 +8,10 @@ module Textus
8
8
  # corruption (the accept gate already refuses them).
9
9
  class ProposalTargets < Check
10
10
  def call
11
- queue = manifest.policy.queue_zone
11
+ queue = manifest.policy.queue_lane
12
12
  return [] unless queue
13
13
 
14
- dispatch(:list, zone: queue).filter_map { |row| issue_for(row["key"]) }
14
+ dispatch(:list, lane: queue).filter_map { |row| issue_for(row["key"]) }
15
15
  end
16
16
 
17
17
  private
@@ -20,7 +20,7 @@ module Textus
20
20
  target = dispatch(:get, key).meta&.dig("proposal", "target_key")
21
21
  return nil if target.nil? # not a proposal entry — skip
22
22
 
23
- zone = manifest.resolver.resolve(target).entry.zone
23
+ zone = manifest.resolver.resolve(target).entry.lane
24
24
  return nil if manifest.policy.declared_kind(zone.to_s) == :canon
25
25
 
26
26
  {
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class RawAssetPaths < Check
5
+ def call
6
+ raw_lane = manifest.policy.lanes_of_kind(:raw).first
7
+ return [] unless raw_lane
8
+
9
+ issues = []
10
+ manifest.resolver.enumerate.each do |row|
11
+ next unless row[:key].start_with?("raw.")
12
+ next unless row[:path] && File.exist?(row[:path])
13
+
14
+ raw_content = load_content(row[:path])
15
+ next unless raw_content.is_a?(Hash)
16
+
17
+ asset = raw_content["asset"]
18
+ next unless asset.is_a?(String) && !asset.empty?
19
+
20
+ asset_path = find_asset_path(asset)
21
+ next if File.exist?(asset_path)
22
+
23
+ issues << {
24
+ "code" => "raw_asset.missing_file",
25
+ "level" => "error",
26
+ "subject" => row[:key],
27
+ "message" => "raw entry '#{row[:key]}' references asset '#{asset}' " \
28
+ "which does not exist at #{asset_path}",
29
+ "fix" => "re-ingest the asset: textus key-delete #{row[:key]}, then textus ingest",
30
+ }
31
+ end
32
+ issues
33
+ end
34
+
35
+ private
36
+
37
+ def load_content(path)
38
+ require "yaml"
39
+ YAML.safe_load_file(path)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def find_asset_path(asset_rel)
45
+ File.join(root, "assets", asset_rel)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -2,7 +2,7 @@ module Textus
2
2
  module Doctor
3
3
  class Check
4
4
  # Flags entries whose key is matched by two or more rule blocks of the
5
- # SAME specificity in the same slot (lifecycle / handler_allowlist /
5
+ # SAME specificity in the same slot (lifecycle / handler_permit /
6
6
  # guard / materialize). Ties are non-deterministic in the parser's pick step, so
7
7
  # they're a configuration smell — surface them.
8
8
  class RuleAmbiguity < Check
@@ -26,7 +26,7 @@ module Textus
26
26
  carriers = matches.select { |b| b.public_send(slot) }
27
27
  return [] if carriers.length < 2
28
28
 
29
- by_specificity = carriers.group_by { |b| Textus::Domain::Policy::Matcher.specificity(b.match) }
29
+ by_specificity = carriers.group_by { |b| Textus::Manifest::Policy::Matcher.specificity(b.match) }
30
30
  tied = by_specificity.values.select { |group| group.length > 1 }
31
31
  tied.map { |group| issue_for(mentry, slot, group) }
32
32
  end
@@ -3,8 +3,14 @@ module Textus
3
3
  class Check
4
4
  class SchemaViolations < Check
5
5
  def call
6
- res = dispatch(:validate_all)
7
- res["violations"].map do |v|
6
+ result = Textus::Doctor::Validator.new(
7
+ reader: ->(key, ctnr, c) { Textus::Action::Get.new(key: key).call(container: ctnr, call: c) },
8
+ manifest: @container.manifest,
9
+ audit_log: @container.audit_log,
10
+ schema_for: ->(name) { @container.schemas.fetch_or_nil(name) },
11
+ ).call(container: @container, call: Textus::Call.build(role: Textus::Role::DEFAULT))
12
+
13
+ result["violations"].map do |v|
8
14
  fix = v["expected"] &&
9
15
  "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
10
16
  {
@@ -14,8 +14,9 @@ module Textus
14
14
  .downcase
15
15
  end
16
16
 
17
- def initialize(container)
17
+ def initialize(container, role: Textus::Role::DEFAULT)
18
18
  @container = container
19
+ @role = role
19
20
  end
20
21
 
21
22
  def call
@@ -26,16 +27,17 @@ module Textus
26
27
 
27
28
  def root = @container.root
28
29
  def manifest = @container.manifest
29
- def rpc = @container.rpc
30
30
 
31
- # Dispatch a verb through the single use-case invocation seam (ADR 0026).
31
+ # Dispatch a verb through Gate.
32
32
  def dispatch(verb, *args, **kwargs)
33
- Textus::Dispatcher.invoke(
34
- verb,
35
- container: @container,
36
- call: Textus::Call.build(role: Textus::Role::DEFAULT),
37
- args: args, kwargs: kwargs
38
- )
33
+ klass = Textus::Action::VERBS[verb]
34
+ spec = klass.contract if klass.respond_to?(:contract?) && klass.contract?
35
+ inputs = spec ? Textus::Contract::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
36
+ cmd_class = Textus::Gate::VERB_COMMAND.fetch(verb)
37
+ merged = inputs.merge(role: @role)
38
+ filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
39
+ cmd = cmd_class.new(**filled)
40
+ @container.gate.dispatch(cmd)
39
41
  end
40
42
  end
41
43
  end
@@ -1,27 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Textus
2
- module Read
4
+ module Doctor
3
5
  class Validator
4
6
  def initialize(reader:, manifest:, audit_log:, schema_for:)
5
- @reader = reader
6
- @manifest = manifest
7
+ @reader = reader
8
+ @manifest = manifest
7
9
  @audit_log = audit_log
8
10
  @schema_for = schema_for
9
11
  end
10
12
 
11
- def call
13
+ def call(container:, call:)
14
+ @container = container
15
+ @call = call
12
16
  violations = []
13
17
  check_content_violations(violations)
14
18
  check_role_authority_violations(violations)
15
- { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
19
+ { "protocol" => Textus::PROTOCOL, "ok" => violations.empty?, "violations" => violations }
16
20
  end
17
21
 
18
22
  private
19
23
 
20
24
  def check_content_violations(violations)
21
25
  @manifest.resolver.enumerate.each do |row|
22
- key = row[:key]
26
+ key = row[:key]
23
27
  mentry = row[:manifest_entry]
24
- env = fetch_envelope(key, violations) or next
28
+ env = fetch_envelope(key, violations) or next
25
29
  schema = mentry.schema && @schema_for.call(mentry.schema)
26
30
  next unless schema
27
31
 
@@ -42,7 +46,7 @@ module Textus
42
46
  next unless schema
43
47
 
44
48
  env = begin
45
- @reader.get(row[:key])
49
+ @reader.call(row[:key], @container, @call)
46
50
  rescue StandardError
47
51
  next
48
52
  end
@@ -54,19 +58,24 @@ module Textus
54
58
  last_writer = @audit_log.last_writer_for(key)
55
59
  return if last_writer.nil?
56
60
 
57
- last_writer_is_authority = @manifest.policy.roles_with_capability("author").include?(last_writer)
58
-
61
+ last_writer_is_authority =
62
+ @manifest.policy.roles_with_capability("author").include?(last_writer)
59
63
  env.meta.each_key do |field|
60
64
  owner = schema.maintained_by(field)
61
65
  next if owner.nil? || last_writer == owner || last_writer_is_authority
62
66
 
63
- violations << { "key" => key, "code" => "role_authority",
64
- "field" => field, "expected" => owner, "last_writer" => last_writer }
67
+ violations << {
68
+ "key" => key,
69
+ "code" => "role_authority",
70
+ "field" => field,
71
+ "expected" => owner,
72
+ "last_writer" => last_writer,
73
+ }
65
74
  end
66
75
  end
67
76
 
68
77
  def fetch_envelope(key, violations)
69
- @reader.get(key)
78
+ @reader.call(key, @container, @call)
70
79
  rescue Textus::Error => e
71
80
  violations << { "key" => key, "code" => e.code, "message" => e.message }
72
81
  nil
data/lib/textus/doctor.rb CHANGED
@@ -1,12 +1,9 @@
1
- require "timeout"
2
-
3
1
  module Textus
4
2
  # Health check for a Textus store. Returns a JSON-friendly Hash envelope
5
3
  # with an `issues` array and a summary. Each issue is a Hash with
6
4
  # `code`, `level`, `subject`, `message`, and optionally `fix`.
7
5
  module Doctor
8
6
  LEVELS = %w[error warning info].freeze
9
- DOCTOR_CHECK_TIMEOUT_SECONDS = 2
10
7
 
11
8
  CHECKS = [
12
9
  Check::ProtocolVersion,
@@ -14,26 +11,25 @@ module Textus
14
11
  Check::Schemas,
15
12
  Check::SchemaParseError,
16
13
  Check::Templates,
17
- Check::Hooks,
18
- Check::IntakeRegistration,
19
14
  Check::IllegalKeys,
20
15
  Check::Sentinels,
21
16
  Check::AuditLog,
22
17
  Check::UnownedSchemaFields,
23
18
  Check::SchemaViolations,
24
19
  Check::RuleAmbiguity,
25
- Check::HandlerAllowlist,
26
20
  Check::OrphanedPublishTargets,
27
21
  Check::PublishTreeIndexOverlap,
28
22
  Check::ProposalTargets,
29
23
  Check::GeneratorDrift,
24
+ Check::RawAssetPaths,
25
+ Check::NotebookSources,
30
26
  ].freeze
31
27
 
32
28
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
33
29
 
34
30
  module_function
35
31
 
36
- def build(container:, checks: nil)
32
+ def build(container:, checks: nil, role: Textus::Role::DEFAULT)
37
33
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
38
34
  unknown = selected_keys - ALL_CHECKS
39
35
  unless unknown.empty?
@@ -43,8 +39,7 @@ module Textus
43
39
  end
44
40
 
45
41
  selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
46
- issues = selected.flat_map { |c| c.new(container).call }
47
- issues.concat(run_registered_checks(container))
42
+ issues = selected.flat_map { |c| c.new(container, role:).call }
48
43
 
49
44
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
50
45
  {
@@ -54,40 +49,5 @@ module Textus
54
49
  "summary" => summary,
55
50
  }
56
51
  end
57
-
58
- def run_registered_checks(container)
59
- container.rpc.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
60
- end
61
-
62
- def invoke_registered_check(container, name)
63
- result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
64
- container.rpc.invoke(:validate, name, caps: container)
65
- end
66
- return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
67
-
68
- [fail_issue(name, code: "doctor_check.bad_return",
69
- message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
70
- fix: "return an array of issue hashes from the doctor_check block")]
71
- rescue Timeout::Error
72
- [fail_issue(name, code: "doctor_check.timeout",
73
- message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
74
- fix: "shorten the check or split it into smaller checks")]
75
- rescue StandardError => e
76
- [fail_issue(name, code: "doctor_check.failed",
77
- message: "#{e.class}: #{e.message}",
78
- fix: "fix the :validate hook in .textus/hooks/")]
79
- end
80
-
81
- def fail_issue(name, code:, message:, fix:)
82
- {
83
- "code" => code,
84
- "level" => "error",
85
- "subject" => name.to_s,
86
- "message" => message,
87
- "fix" => fix,
88
- }
89
- end
90
-
91
- private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
92
52
  end
93
53
  end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ class Envelope
3
+ # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
4
+ # bytes, parses them via the format strategy, and hands back an
5
+ # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
6
+ # (existing-uid lookup for the uid-preservation step in #put).
7
+ #
8
+ # No audit, no events, no permission checks — those live one layer up.
9
+ class Reader
10
+ def self.from(container:)
11
+ new(file_store: container.file_store, manifest: container.manifest)
12
+ end
13
+
14
+ def initialize(file_store:, manifest:)
15
+ @file_store = file_store
16
+ @manifest = manifest
17
+ end
18
+
19
+ def read(key)
20
+ res = @manifest.resolver.resolve(key)
21
+ path = res.path
22
+ return nil unless @file_store.exists?(path)
23
+
24
+ mentry = res.entry
25
+ raw = @file_store.read(path)
26
+ parsed = Format.for(mentry.format).parse(raw, path: path)
27
+ Textus::Envelope.build(
28
+ key: key, mentry: mentry, path: path,
29
+ meta: parsed["_meta"], body: parsed["body"],
30
+ etag: Etag.for_bytes(raw), content: parsed["content"]
31
+ )
32
+ end
33
+
34
+ def existing_uid(key)
35
+ env = read(key)
36
+ env&.uid
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def exists?(key)
42
+ @file_store.exists?(@manifest.resolver.resolve(key).path)
43
+ end
44
+ end
45
+ end
46
+ end