textus 0.54.2 → 0.55.2

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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -1,150 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Core
5
- module Freshness
6
- # The single currency evaluator (ADR 0099). Answers "is the stored data
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).
12
- class Evaluator
13
- def initialize(manifest:, file_stat:, clock:)
14
- @manifest = manifest
15
- @file_stat = file_stat
16
- @clock = clock
17
- end
18
-
19
- # Per-entry currency Verdict driven by the retention rule TTL (if any).
20
- def verdict(mentry)
21
- ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
22
- return fresh if ttl.nil?
23
-
24
- stale = age_stale?(file_basis(mentry), ttl)
25
- Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
26
- end
27
-
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)
31
- end
32
-
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)
37
- path = @manifest.resolver.resolve(mentry.key).path
38
- return nil unless @file_stat.exists?(path)
39
-
40
- @file_stat.mtime(path)
41
- end
42
-
43
- # Generator-drift rows for one entry (replaces Staleness::GeneratorCheck#
44
- # rows_for) — consumed by the doctor generator_drift check.
45
- def drift_rows(mentry)
46
- return [] unless drift_applicable?(mentry)
47
-
48
- path = Textus::Key::Path.resolve(@manifest.data, mentry)
49
- reason = drift_reason(mentry, path)
50
- reason ? [drift_row(mentry, path, reason)] : []
51
- end
52
-
53
- private
54
-
55
- def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
56
-
57
- def due?(mentry, prefix:, lane:)
58
- return false if lane && mentry.lane != lane
59
- return false if prefix && !mentry.key.start_with?(prefix)
60
-
61
- ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
62
- return false if ttl.nil?
63
-
64
- path = @manifest.resolver.resolve(mentry.key).path
65
- return true unless @file_stat.exists?(path)
66
-
67
- age_stale?(file_basis(mentry), ttl)
68
- end
69
-
70
- # The one age comparison. A never-recorded entry (nil basis) is stale.
71
- def age_stale?(basis, ttl)
72
- return true if basis.nil?
73
-
74
- (@clock.now - basis).to_i > ttl
75
- end
76
-
77
- # --- generator drift (lifted from Staleness::GeneratorCheck) ---
78
-
79
- def drift_applicable?(mentry) = mentry.external?
80
-
81
- def drift_reason(mentry, path)
82
- return "derived entry has never been generated" unless @file_stat.exists?(path)
83
-
84
- generated_at = generated_at_of(mentry, path)
85
- return "missing generated.at frontmatter" unless generated_at
86
-
87
- gen_time = parse_time(generated_at)
88
- return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
89
-
90
- offender = newest_source_after(mentry.source, gen_time)
91
- "source '#{offender}' modified after generated.at" if offender
92
- end
93
-
94
- def generated_at_of(mentry, path)
95
- Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
96
- end
97
-
98
- def parse_time(str)
99
- Time.parse(str.to_s)
100
- rescue StandardError
101
- nil
102
- end
103
-
104
- def newest_source_after(external_src, gen_time)
105
- Array(external_src.sources).each do |src|
106
- offender = check_source(src, gen_time)
107
- return offender if offender
108
- end
109
- nil
110
- end
111
-
112
- def check_source(src, gen_time)
113
- if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
114
- @manifest.resolver.enumerate(prefix: src).each do |row|
115
- return src if @file_stat.mtime(row[:path]) > gen_time
116
- end
117
- nil
118
- else
119
- check_filesystem_source(src, gen_time)
120
- end
121
- end
122
-
123
- def check_filesystem_source(src, gen_time)
124
- abs = absolutize_source(src)
125
- if @file_stat.directory?(abs)
126
- dir_has_newer_file?(abs, gen_time) ? src : nil
127
- elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
128
- src
129
- end
130
- end
131
-
132
- def absolutize_source(src)
133
- File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
134
- end
135
-
136
- def dir_has_newer_file?(abs, gen_time)
137
- @file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
138
- file?(fpath) && @file_stat.mtime(fpath) > gen_time
139
- end
140
- end
141
-
142
- def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
143
-
144
- def drift_row(mentry, path, reason)
145
- { "key" => mentry.key, "path" => path, "generator" => mentry.source.command, "reason" => reason }
146
- end
147
- end
148
- end
149
- end
150
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Core
5
- # Currency — "is the stored data stale relative to its source?" (ADR 0099).
6
- # The home of the single Freshness evaluator and its Verdict value object.
7
- # Distinct from Core::Retention (GC dueness, Q2).
8
- module Freshness
9
- end
10
- end
11
- end
@@ -1,57 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Core
5
- module Retention
6
- # Retention sweep reporter (ADR 0093/0099). Which entries are past their
7
- # `retention:` ttl and the destructive action that applies. Age basis: file
8
- # mtime. Only drop/archive. Renamed off the Core::Retention vs
9
- # Manifest::Policy::Retention collision (ADR 0099).
10
- class Sweep
11
- def self.expired?(ttl_seconds:, mtime:, now:)
12
- return false if ttl_seconds.nil? || mtime.nil?
13
-
14
- (now - mtime).to_i > ttl_seconds
15
- end
16
-
17
- def initialize(manifest:, file_stat:, clock:)
18
- @manifest = manifest
19
- @file_stat = file_stat
20
- @clock = clock
21
- end
22
-
23
- def call(prefix: nil, lane: nil)
24
- @manifest.data.entries
25
- .select { |m| matches?(m, prefix: prefix, lane: lane) }
26
- .flat_map { |m| rows_for(m) }
27
- end
28
-
29
- private
30
-
31
- def matches?(mentry, prefix:, lane:)
32
- return false if lane && mentry.lane != lane
33
- return false if prefix && !Textus::Key::Matching.matches_prefix?(
34
- mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
35
- )
36
-
37
- true
38
- end
39
-
40
- def rows_for(mentry)
41
- policy = @manifest.rules.for(mentry.key).retention
42
- return [] if policy.nil?
43
-
44
- @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
45
- path = row[:path]
46
- next unless @file_stat.exists?(path)
47
- next unless self.class.expired?(
48
- ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
49
- )
50
-
51
- { "key" => row[:key], "path" => path, "action" => policy.action.to_s }
52
- end
53
- end
54
- end
55
- end
56
- end
57
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Core
5
- # Retention — "is the entry old enough to retire?" (Q2, ADR 0093/0099).
6
- # GC dueness, orthogonal to Freshness (content currency). The reporter is
7
- # Core::Retention::Sweep; the manifest rule policy is Manifest::Policy::Retention.
8
- module Retention
9
- end
10
- end
11
- end
@@ -1,24 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- # Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
5
- # `textus pulse` (no --since) means "since I last looked". Gitignored;
6
- # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
7
- class CursorStore
8
- def initialize(root:, role:)
9
- @path = Textus::Layout.cursor(root, role)
10
- end
11
-
12
- def read
13
- Integer(File.read(@path).strip)
14
- rescue Errno::ENOENT, ArgumentError
15
- 0
16
- end
17
-
18
- def write(seq)
19
- FileUtils.mkdir_p(File.dirname(@path))
20
- File.write(@path, seq.to_s)
21
- seq
22
- end
23
- end
24
- end
@@ -1,46 +0,0 @@
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
@@ -1,209 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- class Envelope
5
- # Owns the write pipeline (validate, serialize, etag-check, write, audit).
6
- # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
7
- # Reader for the existing-uid lookup.
8
- #
9
- # Invariant: every public method's final action is @audit_log.append(...).
10
- #
11
- # No permission check, no event firing — those belong to the caller
12
- # (Write::Put / ::Delete / ::Mv).
13
- class Writer
14
- Payload = Data.define(:meta, :body, :content)
15
-
16
- def self.from(container:, call:)
17
- new(
18
- file_store: container.file_store, manifest: container.manifest,
19
- schemas: container.schemas, audit_log: container.audit_log,
20
- call: call, reader: Reader.from(container: container)
21
- )
22
- end
23
-
24
- def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
25
- @file_store = file_store
26
- @manifest = manifest
27
- @schemas = schemas
28
- @audit_log = audit_log
29
- @call = call
30
- @reader = reader
31
- end
32
-
33
- def put(key, mentry:, payload:, if_etag: nil)
34
- path = resolve_path(key)
35
- meta, content = prepare_uid(mentry, payload, key)
36
- bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
37
- enforce_name_match!(path, eff_meta, mentry.format)
38
- validate_schema(mentry, eff_meta, eff_content)
39
- etag_before = check_etag!(path, key, if_etag)
40
- write_bytes(path, bytes)
41
- envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
42
- audit_put(key, etag_before, envelope.etag)
43
- envelope
44
- end
45
-
46
- def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
47
- # `mentry:` is accepted for symmetry with `put` / `move` and to
48
- # leave room for future format-specific delete hooks; no field
49
- # on it is needed today.
50
- path = @manifest.resolver.resolve(key).path
51
- raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
52
-
53
- etag_before = @file_store.etag(path)
54
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
55
-
56
- @file_store.delete(path)
57
- prune_empty_parents(path)
58
- @audit_log.append(
59
- role: @call.role, verb: "key_delete", key: key,
60
- etag_before: etag_before, etag_after: nil,
61
- extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
62
- )
63
- end
64
-
65
- def move(from_key:, to_key:, new_mentry:, if_etag: nil)
66
- from_path = @manifest.resolver.resolve(from_key).path
67
- to_path = @manifest.resolver.resolve(to_key).path
68
- raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
69
-
70
- etag_before = @file_store.etag(from_path)
71
- raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
72
-
73
- FileUtils.mkdir_p(File.dirname(to_path))
74
- FileUtils.mv(from_path, to_path)
75
- prune_empty_parents(from_path)
76
- basename = to_key.split(".").last
77
- Format.for(new_mentry.format).rewrite_name(to_path, basename)
78
- etag_after = Etag.for_file(to_path)
79
-
80
- raw = @file_store.read(to_path)
81
- parsed = Format.for(new_mentry.format).parse(raw, path: to_path)
82
- envelope = Textus::Envelope.build(
83
- key: to_key, mentry: new_mentry, path: to_path,
84
- meta: parsed["_meta"], body: parsed["body"],
85
- etag: etag_after, content: parsed["content"]
86
- )
87
-
88
- extras = {
89
- "from_key" => from_key, "to_key" => to_key,
90
- "from_path" => from_path, "to_path" => to_path,
91
- "uid" => envelope.uid
92
- }
93
- extras["correlation_id"] = @call.correlation_id if @call.correlation_id
94
-
95
- @audit_log.append(
96
- role: @call.role, verb: "key_mv", key: to_key,
97
- etag_before: etag_before, etag_after: etag_after,
98
- extras: extras
99
- )
100
-
101
- envelope
102
- end
103
-
104
- private
105
-
106
- # After a file leaves a directory (delete or move-source), remove any
107
- # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
108
- # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
109
- # declared, first-class container, so its own dir is preserved even when
110
- # momentarily empty; only the sub-dirs the bulk op carved out are
111
- # pruned. Stops at the first non-empty ancestor, so a dir holding a
112
- # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
113
- # non-empty dir is silently fine, never fatal to the write.
114
- def prune_empty_parents(path)
115
- floor = zone_floor(path)
116
- return unless floor
117
-
118
- dir = File.dirname(path)
119
- while dir.start_with?("#{floor}/") && Dir.empty?(dir)
120
- Dir.rmdir(dir)
121
- dir = File.dirname(dir)
122
- end
123
- rescue SystemCallError
124
- nil
125
- end
126
-
127
- # The zone directory under which `path` lives (`<root>/zones/<zone>`),
128
- # or nil if `path` is not under the store's zones tree.
129
- def zone_floor(path)
130
- zones_root = File.join(@manifest.data.root, "data")
131
- prefix = "#{zones_root}/"
132
- return nil unless path.start_with?(prefix)
133
-
134
- zone_seg = path.delete_prefix(prefix).split("/").first
135
- zone_seg && File.join(zones_root, zone_seg)
136
- end
137
-
138
- def ensure_uid(format, meta, content, existing_uid)
139
- Textus::Format.for(format).inject_uid(meta, content, existing_uid)
140
- end
141
-
142
- def enforce_name_match!(path, meta, format)
143
- Textus::Format.for(format).enforce_name_match!(path, meta)
144
- end
145
-
146
- def serialize_for_put(mentry:, path:, meta:, body:, content:)
147
- Textus::Format.for(mentry.format).serialize_for_put(
148
- meta: meta, body: body, content: content, path: path,
149
- )
150
- end
151
-
152
- def resolve_path(key)
153
- @manifest.resolver.resolve(key).path
154
- end
155
-
156
- def prepare_uid(mentry, payload, key)
157
- meta = payload.meta || {}
158
- existing_uid = @reader.existing_uid(key)
159
- ensure_uid(mentry.format, meta, payload.content, existing_uid)
160
- end
161
-
162
- def serialize_entry(mentry, path, meta, payload, content)
163
- serialize_for_put(
164
- mentry: mentry, path: path,
165
- meta: meta, body: payload.body, content: content
166
- )
167
- end
168
-
169
- def validate_schema(mentry, eff_meta, eff_content)
170
- schema = @schemas.fetch_or_nil(mentry.schema)
171
- return unless schema
172
-
173
- Format.for(mentry.format).validate_against(
174
- schema,
175
- { "_meta" => eff_meta, "content" => eff_content },
176
- )
177
- end
178
-
179
- def check_etag!(path, key, if_etag)
180
- etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
181
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
182
-
183
- etag_before
184
- end
185
-
186
- def write_bytes(path, bytes)
187
- @file_store.write(path, bytes)
188
- end
189
-
190
- def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
191
- Textus::Envelope.build(
192
- key: key, mentry: mentry, path: path,
193
- meta: eff_meta, body: eff_body,
194
- etag: Etag.for_bytes(@file_store.read(path)),
195
- content: eff_content
196
- )
197
- end
198
-
199
- def audit_put(key, etag_before, etag_after)
200
- extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
201
- @audit_log.append(
202
- role: @call.role, verb: "put", key: key,
203
- etag_before: etag_before, etag_after: etag_after,
204
- extras: extras
205
- )
206
- end
207
- end
208
- end
209
- end
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry-struct"
4
-
5
- module Textus
6
- class Envelope < Dry::Struct
7
- attribute :protocol, Types::String
8
- attribute :key, Types::String
9
- attribute :lane, Types::String
10
- attribute :owner, Types::String.optional
11
- attribute :path, Types::String
12
- attribute :format, Types::FormatName
13
- attribute :etag, Types::String
14
- attribute :uid, Types::String.optional
15
- attribute :schema_ref, Types::String.optional
16
- attribute :meta, Types::Hash.default({}.freeze)
17
- attribute :body, Types::String.optional
18
- attribute :content, Types::Any.optional
19
- attribute :freshness, Types::Any.optional
20
-
21
- # rubocop:disable Metrics/ParameterLists
22
- def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
23
- # rubocop:enable Metrics/ParameterLists
24
- new(
25
- protocol: Textus::PROTOCOL,
26
- key: key,
27
- lane: mentry.lane,
28
- owner: mentry.owner,
29
- path: path,
30
- format: mentry.format,
31
- uid: extract_uid(meta),
32
- etag: etag,
33
- schema_ref: mentry.schema,
34
- meta: meta,
35
- body: body,
36
- content: content,
37
- freshness: freshness,
38
- )
39
- end
40
-
41
- def self.extract_uid(meta)
42
- v = meta.is_a?(Hash) ? meta["uid"] : nil
43
- v.is_a?(String) ? v : nil
44
- end
45
-
46
- def with(**attrs) = self.class.new(to_h.merge(attrs))
47
-
48
- def to_h_for_wire
49
- h = {
50
- "protocol" => protocol,
51
- "key" => key,
52
- "lane" => lane,
53
- "owner" => owner,
54
- "path" => path,
55
- "format" => format,
56
- "_meta" => meta,
57
- "body" => body,
58
- "etag" => etag,
59
- "schema_ref" => schema_ref,
60
- "uid" => uid,
61
- }
62
- h["content"] = content unless content.nil?
63
- freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
64
- h
65
- end
66
-
67
- def stale?
68
- return false if freshness.nil?
69
-
70
- freshness.stale == true
71
- end
72
-
73
- def fetching?
74
- return false if freshness.nil?
75
-
76
- freshness.fetching == true
77
- end
78
- end
79
- end
data/lib/textus/etag.rb DELETED
@@ -1,36 +0,0 @@
1
- require "digest"
2
-
3
- module Textus
4
- module Etag
5
- def self.for_bytes(bytes)
6
- "sha256:#{Digest::SHA256.hexdigest(bytes)}"
7
- end
8
-
9
- def self.for_file(path)
10
- for_bytes(File.binread(path))
11
- end
12
-
13
- # The fingerprint of everything an agent's boot orientation depends on:
14
- # the manifest PLUS the executable contract — hooks and schemas. A
15
- # mid-session edit to any of these makes the cached orientation stale, so
16
- # the session must re-boot (ADR 0074). The composite is one digest over the
17
- # sorted per-file listing, so it is order-stable.
18
- def self.for_contract(root)
19
- listing = contract_files(root).map do |path|
20
- rel = path.delete_prefix(root).delete_prefix("/")
21
- "#{rel}:#{for_file(path)}"
22
- end.join("\n")
23
- for_bytes(listing)
24
- end
25
-
26
- # manifest.yaml, then every hook and schema file. Dir.glob already returns
27
- # sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
28
- def self.contract_files(root)
29
- [
30
- File.join(root, "manifest.yaml"),
31
- *Dir.glob(File.join(root, "hooks", "**", "*.rb")),
32
- *Dir.glob(File.join(root, "schemas", "**", "*")).select { |f| File.file?(f) },
33
- ]
34
- end
35
- end
36
- end