textus 0.47.1 → 0.49.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +9 -7
  4. data/SPEC.md +40 -46
  5. data/docs/reference/conventions.md +11 -10
  6. data/lib/textus/boot.rb +2 -2
  7. data/lib/textus/cli/runner.rb +5 -4
  8. data/lib/textus/dispatcher.rb +3 -8
  9. data/lib/textus/doctor/check/generator_drift.rb +28 -0
  10. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
  11. data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
  12. data/lib/textus/doctor.rb +2 -0
  13. data/lib/textus/domain/lifecycle.rb +83 -0
  14. data/lib/textus/domain/policy/base_guards.rb +2 -2
  15. data/lib/textus/domain/policy/lifecycle.rb +35 -0
  16. data/lib/textus/domain/staleness.rb +6 -3
  17. data/lib/textus/envelope/io/writer.rb +2 -2
  18. data/lib/textus/hooks/context.rb +1 -1
  19. data/lib/textus/init.rb +4 -4
  20. data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
  21. data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
  22. data/lib/textus/maintenance/tend.rb +110 -0
  23. data/lib/textus/manifest/rules.rb +11 -23
  24. data/lib/textus/manifest/schema.rb +3 -18
  25. data/lib/textus/ports/audit_log.rb +1 -1
  26. data/lib/textus/ports/audit_subscriber.rb +1 -1
  27. data/lib/textus/ports/fetch/detached.rb +5 -1
  28. data/lib/textus/read/freshness.rb +29 -22
  29. data/lib/textus/read/get.rb +47 -32
  30. data/lib/textus/read/pulse.rb +1 -1
  31. data/lib/textus/read/rule_explain.rb +10 -16
  32. data/lib/textus/read/rule_list.rb +5 -7
  33. data/lib/textus/version.rb +1 -1
  34. data/lib/textus/write/accept.rb +1 -1
  35. data/lib/textus/write/fetch_worker.rb +8 -12
  36. data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
  37. data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
  38. data/lib/textus/write/reject.rb +1 -1
  39. metadata +8 -15
  40. data/lib/textus/cli/group/fetch.rb +0 -20
  41. data/lib/textus/cli/verb/fetch.rb +0 -14
  42. data/lib/textus/cli/verb/fetch_all.rb +0 -20
  43. data/lib/textus/domain/policy/fetch.rb +0 -37
  44. data/lib/textus/domain/policy/retention.rb +0 -26
  45. data/lib/textus/domain/retention.rb +0 -44
  46. data/lib/textus/domain/staleness/intake_check.rb +0 -54
  47. data/lib/textus/maintenance/migrate.rb +0 -65
  48. data/lib/textus/read/retainable.rb +0 -17
  49. data/lib/textus/read/stale.rb +0 -17
  50. data/lib/textus/write/fetch_all.rb +0 -53
  51. data/lib/textus/write/retention_sweep.rb +0 -64
@@ -0,0 +1,83 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ module Domain
5
+ # Unified lifecycle reporter (ADR 0079): which entries are past their ttl,
6
+ # and the on_expire action that applies. Replaces both Staleness::IntakeCheck
7
+ # and Retention. Age basis: _meta.last_fetched_at (intake) when present, else
8
+ # file mtime (stored). `self.verdict` is the pure per-entry decision that BOTH
9
+ # this reporter and `Read::Get` (Plan 2) call, so the basis logic lives once.
10
+ class Lifecycle
11
+ # Pure: is the entry past its ttl? -> [expired(bool), reason(String|nil)].
12
+ def self.verdict(policy:, last_fetched_at:, mtime:, now:)
13
+ ttl = policy.ttl_seconds
14
+ return [false, nil] if ttl.nil?
15
+
16
+ basis = parse_time(last_fetched_at) || mtime
17
+ return [true, "never recorded"] if basis.nil?
18
+
19
+ age = (now - basis).to_i
20
+ age > ttl ? [true, "ttl exceeded (age=#{age}s, ttl=#{ttl}s)"] : [false, nil]
21
+ end
22
+
23
+ def self.parse_time(str)
24
+ return nil if str.nil?
25
+
26
+ Time.parse(str.to_s)
27
+ rescue ArgumentError, TypeError
28
+ nil
29
+ end
30
+
31
+ def initialize(manifest:, file_stat:, clock:)
32
+ @manifest = manifest
33
+ @file_stat = file_stat
34
+ @clock = clock
35
+ end
36
+
37
+ def call(prefix: nil, zone: nil)
38
+ @manifest.data.entries
39
+ .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
40
+ .flat_map { |m| rows_for(m) }
41
+ end
42
+
43
+ private
44
+
45
+ def entry_matches?(mentry, prefix:, zone:)
46
+ return false if zone && mentry.zone != zone
47
+ return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
48
+
49
+ true
50
+ end
51
+
52
+ def rows_for(mentry)
53
+ policy = @manifest.rules.for(mentry.key).lifecycle
54
+ return [] if policy.nil?
55
+
56
+ @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
57
+ path = row[:path]
58
+ next unless @file_stat.exists?(path)
59
+
60
+ expired, _reason = self.class.verdict(
61
+ policy: policy,
62
+ last_fetched_at: last_fetched_at_of(mentry, path),
63
+ mtime: @file_stat.mtime(path),
64
+ now: @clock.now,
65
+ )
66
+ next unless expired
67
+
68
+ {
69
+ "key" => row[:key], "path" => path,
70
+ "action" => policy.on_expire.to_s, "expired" => true
71
+ }
72
+ end
73
+ end
74
+
75
+ # Reads _meta.last_fetched_at from the on-disk envelope (intake basis).
76
+ def last_fetched_at_of(mentry, path)
77
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
@@ -11,8 +11,8 @@ module Textus
11
11
  # composable-only, added per-key via rules[].guard (ADR 0031).
12
12
  BASE = {
13
13
  put: %w[zone_writable_by],
14
- delete: %w[zone_writable_by],
15
- mv: %w[zone_writable_by],
14
+ key_delete: %w[zone_writable_by],
15
+ key_mv: %w[zone_writable_by],
16
16
  accept: %w[author_held target_is_canon],
17
17
  reject: %w[author_held],
18
18
  fetch: %w[zone_writable_by],
@@ -0,0 +1,35 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ # Unified per-entry lifecycle policy (ADR 0079): one ttl + one action.
5
+ # Replaces the separate Fetch (ttl/on_stale) and Retention
6
+ # (expire_after/archive_after) policies. The action's destructiveness
7
+ # decides WHERE it runs: lazy actions (refresh/warn) on get/list reads;
8
+ # destructive actions (drop/archive) only on the tend sweep.
9
+ class Lifecycle
10
+ LAZY = %i[refresh warn].freeze
11
+ DESTRUCTIVE = %i[drop archive].freeze
12
+ ALLOWED = (LAZY + DESTRUCTIVE).freeze
13
+
14
+ attr_reader :on_expire, :budget_ms
15
+
16
+ def initialize(ttl:, on_expire:, budget_ms: nil)
17
+ action = on_expire.is_a?(Symbol) ? on_expire : on_expire.to_s.to_sym
18
+ unless ALLOWED.include?(action)
19
+ raise Textus::UsageError.new(
20
+ "lifecycle on_expire must be one of #{ALLOWED.join("|")}, got #{on_expire.inspect}",
21
+ )
22
+ end
23
+
24
+ @ttl = ttl
25
+ @on_expire = action
26
+ @budget_ms = budget_ms
27
+ end
28
+
29
+ def ttl_seconds = Textus::Domain::Duration.seconds(@ttl)
30
+ def destructive? = DESTRUCTIVE.include?(@on_expire)
31
+ def lazy? = LAZY.include?(@on_expire)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -1,16 +1,19 @@
1
1
  module Textus
2
2
  module Domain
3
3
  class Staleness
4
- def initialize(manifest:, file_stat:, clock:)
4
+ # ADR 0079: intake (age-based) staleness moved to the unified lifecycle
5
+ # path (Domain::Lifecycle / freshness); only generator/build drift —
6
+ # dependency-based, surfaced by the doctor `generator_drift` check —
7
+ # remains here.
8
+ def initialize(manifest:, file_stat:, clock: nil) # rubocop:disable Lint/UnusedMethodArgument
5
9
  @manifest = manifest
6
10
  @generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
7
- @intake_check = IntakeCheck.new(manifest: manifest, file_stat: file_stat, clock: clock)
8
11
  end
9
12
 
10
13
  def call(prefix: nil, zone: nil)
11
14
  @manifest.data.entries
12
15
  .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
13
- .flat_map { |m| @generator_check.rows_for(m) + @intake_check.rows_for(m) }
16
+ .flat_map { |m| @generator_check.rows_for(m) }
14
17
  end
15
18
 
16
19
  private
@@ -84,7 +84,7 @@ module Textus
84
84
  @file_store.delete(path)
85
85
  prune_empty_parents(path)
86
86
  @audit_log.append(
87
- role: @call.role, verb: "delete", key: key,
87
+ role: @call.role, verb: "key_delete", key: key,
88
88
  etag_before: etag_before, etag_after: nil,
89
89
  extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
90
90
  )
@@ -121,7 +121,7 @@ module Textus
121
121
  extras["correlation_id"] = @call.correlation_id if @call.correlation_id
122
122
 
123
123
  @audit_log.append(
124
- role: @call.role, verb: "mv", key: to_key,
124
+ role: @call.role, verb: "key_mv", key: to_key,
125
125
  etag_before: etag_before, etag_after: etag_after,
126
126
  extras: extras
127
127
  )
@@ -45,7 +45,7 @@ module Textus
45
45
 
46
46
  # write (authorized + audited)
47
47
  def put(key, **) = @scope.put(key, **)
48
- def delete(key, **) = @scope.delete(key, **)
48
+ def delete(key, **) = @scope.key_delete(key, **)
49
49
 
50
50
  def audit(verb, key:, **)
51
51
  @scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
data/lib/textus/init.rb CHANGED
@@ -41,7 +41,7 @@ module Textus
41
41
  local: { via: local }
42
42
  rules:
43
43
  - match: feeds.machines.**
44
- fetch: { ttl: 1h, on_stale: warn } # meaningful on a long-running server
44
+ lifecycle: { ttl: 1h, on_expire: warn } # meaningful on a long-running server
45
45
  YAML
46
46
 
47
47
  HOOKS_README = <<~MD
@@ -72,7 +72,7 @@ module Textus
72
72
  ```
73
73
 
74
74
  The intake handler above is paired with a manifest entry plus a
75
- top-level `rules:` block for freshness (ttl/on_stale live in
75
+ top-level `rules:` block for lifecycle (ttl/on_expire live in
76
76
  rules, not in the entry):
77
77
 
78
78
  ```yaml
@@ -86,9 +86,9 @@ module Textus
86
86
 
87
87
  rules:
88
88
  - match: feeds.foo
89
- fetch:
89
+ lifecycle:
90
90
  ttl: 10m
91
- on_stale: timed_sync # warn | sync | timed_sync (default: warn)
91
+ on_expire: refresh # refresh | warn (intake); drop | archive (stored)
92
92
  ```
93
93
 
94
94
  Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
@@ -41,7 +41,7 @@ module Textus
41
41
  private
42
42
 
43
43
  def delete
44
- Write::Delete.new(container: @container, call: @call)
44
+ Write::KeyDelete.new(container: @container, call: @call)
45
45
  end
46
46
  end
47
47
  end
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Maintenance
3
3
  # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
- # Calls Write::Mv directly for each entry — emits one audit row per file moved.
4
+ # Calls Write::KeyMv directly for each entry — emits one audit row per file moved.
5
5
  class KeyMvPrefix
6
6
  extend Textus::Contract::DSL
7
7
 
@@ -61,7 +61,7 @@ module Textus
61
61
  end
62
62
 
63
63
  def mv
64
- Write::Mv.new(container: @container, call: @call)
64
+ Write::KeyMv.new(container: @container, call: @call)
65
65
  end
66
66
  end
67
67
  end
@@ -0,0 +1,110 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # The destructive-only lifecycle sweep (ADR 0079, supersedes the composite
6
+ # 0078 body). Drives off the unified Domain::Lifecycle reporter: it applies
7
+ # destructive actions a read never performs (drop = delete via Write::KeyDelete;
8
+ # archive = copy to <store>/archive/ then delete) and refreshes cold expired
9
+ # intake entries (on_expire: refresh) via Write::FetchWorker. Non-destructive
10
+ # annotation (warn) is left to the lazy `get`/`freshness` path. Adds no new
11
+ # authority — every sub-op runs with the CALLER's own `call` (role), and is
12
+ # gated exactly as on its own.
13
+ class Tend
14
+ extend Textus::Contract::DSL
15
+
16
+ verb :tend
17
+ summary "Run the destructive lifecycle sweep: drop/archive expired entries, refresh cold intake, report health."
18
+ surfaces :cli, :mcp
19
+ cli "tend"
20
+ arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
21
+ arg :zone, String, description: "restrict the sweep to entries in this zone"
22
+ arg :dry_run, :boolean, default: false,
23
+ description: "when true, report what the sweep WOULD do without applying; " \
24
+ "defaults to false, so omitting it drops/archives/refreshes immediately"
25
+
26
+ def initialize(container:, call:)
27
+ @container = container
28
+ @call = call
29
+ end
30
+
31
+ def call(prefix: nil, zone: nil, dry_run: false)
32
+ rows = Textus::Domain::Lifecycle.new(
33
+ manifest: @container.manifest,
34
+ file_stat: Textus::Ports::Storage::FileStat.new,
35
+ clock: Textus::Ports::Clock,
36
+ ).call(prefix: prefix, zone: zone)
37
+
38
+ health = Read::Doctor.new(container: @container, call: @call).call
39
+ return dry_run_result(rows, health) if dry_run
40
+
41
+ apply_result(apply(rows), health)
42
+ end
43
+
44
+ private
45
+
46
+ def dry_run_result(rows, health)
47
+ {
48
+ "protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
49
+ "would_drop" => action_keys(rows, "drop"),
50
+ "would_archive" => action_keys(rows, "archive"),
51
+ "would_refresh" => action_keys(rows, "refresh"),
52
+ "health" => health
53
+ }
54
+ end
55
+
56
+ def apply_result(result, health)
57
+ {
58
+ "protocol" => Textus::PROTOCOL,
59
+ "ok" => result[:failed].empty?,
60
+ "dry_run" => false,
61
+ "dropped" => result[:dropped], "archived" => result[:archived],
62
+ "refreshed" => result[:refreshed], "failed" => result[:failed],
63
+ "health" => health
64
+ }
65
+ end
66
+
67
+ def action_keys(rows, action)
68
+ rows.select { |r| r["action"] == action }.map { |r| r["key"] }
69
+ end
70
+
71
+ def apply(rows)
72
+ out = { dropped: [], archived: [], refreshed: [], failed: [] }
73
+ delete = Write::KeyDelete.new(container: @container, call: @call)
74
+ refresh = Write::FetchWorker.new(container: @container, call: @call)
75
+
76
+ rows.each do |row|
77
+ key = row["key"]
78
+ begin
79
+ case row["action"]
80
+ when "drop"
81
+ delete.call(key)
82
+ out[:dropped] << key
83
+ when "archive"
84
+ archive_leaf(row)
85
+ delete.call(key)
86
+ out[:archived] << key
87
+ when "refresh"
88
+ refresh.run(key)
89
+ out[:refreshed] << key
90
+ end
91
+ rescue Textus::Error => e
92
+ out[:failed] << { "key" => key, "error" => e.message }
93
+ end
94
+ end
95
+ out
96
+ end
97
+
98
+ # Copy the leaf into <store>/archive/<relative-path> before deletion.
99
+ # (Lifted from the retired RetentionSweep#archive_leaf.)
100
+ def archive_leaf(row)
101
+ src = row["path"]
102
+ root = @container.root.to_s
103
+ rel = src.delete_prefix("#{root}/")
104
+ dest = File.join(root, "archive", rel)
105
+ FileUtils.mkdir_p(File.dirname(dest))
106
+ FileUtils.cp(src, dest)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Rules
4
- RuleSet = ::Data.define(:fetch, :handler_allowlist, :guard, :retention)
5
- EMPTY_SET = RuleSet.new(fetch: nil, handler_allowlist: nil, guard: nil, retention: nil)
4
+ RuleSet = ::Data.define(:handler_allowlist, :guard, :lifecycle)
5
+ EMPTY_SET = RuleSet.new(handler_allowlist: nil, guard: nil, lifecycle: nil)
6
6
 
7
7
  def self.parse(raw)
8
8
  new(Array(raw).map { |b| Block.new(b) })
@@ -15,17 +15,16 @@ module Textus
15
15
  attr_reader :blocks
16
16
 
17
17
  def for(key)
18
- slots = { fetch: [], handler_allowlist: [], guard: [], retention: [] }
18
+ slots = { handler_allowlist: [], guard: [], lifecycle: [] }
19
19
  @blocks.each do |b|
20
20
  next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
21
21
 
22
22
  slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
23
23
  end
24
24
  RuleSet.new(
25
- fetch: pick(slots[:fetch], :fetch, key),
26
25
  handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
27
26
  guard: pick(slots[:guard], :guard, key),
28
- retention: pick(slots[:retention], :retention, key),
27
+ lifecycle: pick(slots[:lifecycle], :lifecycle, key),
29
28
  )
30
29
  end
31
30
 
@@ -44,29 +43,17 @@ module Textus
44
43
  end
45
44
 
46
45
  class Block
47
- attr_reader :match, :fetch, :handler_allowlist, :guard, :retention
46
+ attr_reader :match, :handler_allowlist, :guard, :lifecycle
48
47
 
49
48
  def initialize(raw)
50
49
  @match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
51
- @fetch = parse_fetch(raw["fetch"])
52
50
  @handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
53
51
  @guard = parse_guard(raw["guard"])
54
- @retention = parse_retention(raw["retention"])
52
+ @lifecycle = parse_lifecycle(raw["lifecycle"])
55
53
  end
56
54
 
57
55
  private
58
56
 
59
- def parse_fetch(h)
60
- return nil if h.nil?
61
-
62
- Textus::Domain::Policy::Fetch.new(
63
- ttl: h["ttl"],
64
- on_stale: h["on_stale"] || "warn",
65
- sync_budget_ms: h["sync_budget_ms"],
66
- fetch_timeout_seconds: h["fetch_timeout_seconds"],
67
- )
68
- end
69
-
70
57
  def parse_handler_allowlist(arr)
71
58
  return nil if arr.nil?
72
59
 
@@ -83,12 +70,13 @@ module Textus
83
70
  h
84
71
  end
85
72
 
86
- def parse_retention(h)
73
+ def parse_lifecycle(h)
87
74
  return nil if h.nil?
88
75
 
89
- Textus::Domain::Policy::Retention.new(
90
- expire_after: h["expire_after"],
91
- archive_after: h["archive_after"],
76
+ Textus::Domain::Policy::Lifecycle.new(
77
+ ttl: h["ttl"],
78
+ on_expire: h["on_expire"],
79
+ budget_ms: h["budget_ms"],
92
80
  )
93
81
  end
94
82
  end
@@ -32,10 +32,8 @@ module Textus
32
32
  PUBLISH_KEYS = %w[to tree].freeze
33
33
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
34
34
  INTAKE_KEYS = %w[handler config].freeze
35
- RULE_KEYS = %w[match fetch intake_handler_allowlist guard retention].freeze
36
- FETCH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
37
- FETCH_TIMEOUT_SECONDS_CEILING = 3600
38
- RETENTION_KEYS = %w[expire_after archive_after].freeze
35
+ RULE_KEYS = %w[match intake_handler_allowlist guard lifecycle].freeze
36
+ LIFECYCLE_KEYS = %w[ttl on_expire budget_ms].freeze
39
37
  AUDIT_KEYS = %w[max_size keep].freeze
40
38
 
41
39
  # Syntactic shape of an `owner:` subject token (the `patrick` in
@@ -136,11 +134,7 @@ module Textus
136
134
  Array(rules).each_with_index do |r, i|
137
135
  path = "$.rules[#{i}]"
138
136
  walk(r, RULE_KEYS, path)
139
- if r["fetch"].is_a?(Hash)
140
- walk(r["fetch"], FETCH_KEYS, "#{path}.fetch")
141
- validate_fetch_timeout!(r["fetch"]["fetch_timeout_seconds"], "#{path}.fetch.fetch_timeout_seconds")
142
- end
143
- walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
137
+ walk(r["lifecycle"], LIFECYCLE_KEYS, "#{path}.lifecycle") if r["lifecycle"].is_a?(Hash)
144
138
  end
145
139
  end
146
140
 
@@ -217,15 +211,6 @@ module Textus
217
211
  OWNER_SUBJECT_PATTERN.match?(subject)
218
212
  end
219
213
 
220
- def self.validate_fetch_timeout!(value, path)
221
- return if value.nil?
222
- return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
223
-
224
- raise BadManifest.new(
225
- "fetch_timeout_seconds at '#{path}' must be a positive integer ≤ #{FETCH_TIMEOUT_SECONDS_CEILING} (got #{value.inspect})",
226
- )
227
- end
228
-
229
214
  def self.walk(hash, allowed, path)
230
215
  return unless hash.is_a?(Hash)
231
216
 
@@ -23,7 +23,7 @@ module Textus
23
23
  parsed = parse_row(line.chomp)
24
24
  next unless parsed
25
25
  next unless parsed["key"] == key
26
- next unless %w[put delete].include?(parsed["verb"])
26
+ next unless %w[put delete key_delete].include?(parsed["verb"])
27
27
 
28
28
  last_role = parsed["role"]
29
29
  end
@@ -10,7 +10,7 @@ module Textus
10
10
  # rescue and the failure is a bus-internal concern, not a domain
11
11
  # event subscribers should be able to filter by key glob).
12
12
  #
13
- # Lifecycle audit rows for verb: "put" / "delete" / "rename" are written
13
+ # Lifecycle audit rows for verb: "put" / "key_delete" / "key_mv" are written
14
14
  # by Envelope::IO::Writer directly (it owns the
15
15
  # audit-append-as-final-step invariant); this subscriber covers the
16
16
  # hook-failure case the writer never sees.
@@ -31,7 +31,11 @@ module Textus
31
31
  # exists). Config-time detection is doctor's job (ADR 0044 Q2).
32
32
  role = acting_role(store)
33
33
  exit(0) unless role
34
- store.as(role).fetch(key)
34
+ # FetchWorker is the internal executor since the public `fetch`
35
+ # verb was collapsed (ADR 0079); drive it directly.
36
+ Textus::Write::FetchWorker.new(
37
+ container: store.container, call: Textus::Call.build(role: role),
38
+ ).run(key)
35
39
  rescue StandardError
36
40
  # Already logged via :fetch_failed; exit cleanly.
37
41
  ensure
@@ -2,10 +2,10 @@ require "time"
2
2
 
3
3
  module Textus
4
4
  module Read
5
- # Per-entry freshness report. Walks every entry declared in the manifest,
6
- # consults `rules_for(key)` for a fetch rule, and reports the
7
- # current status. Status is one of :fresh, :stale, :never_fetched, or
8
- # :no_policy.
5
+ # Per-entry lifecycle report (ADR 0079). Walks every entry declared in the
6
+ # manifest, consults `rules.for(key)` for a `lifecycle:` policy, and reports
7
+ # the unified verdict. Status is one of :fresh, :expired, or :no_policy; the
8
+ # row also carries the policy's :action (on_expire).
9
9
  class Freshness
10
10
  extend Textus::Contract::DSL
11
11
 
@@ -17,13 +17,11 @@ module Textus
17
17
  arg :zone, String, required: false, description: "filter to entries in this zone"
18
18
  view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
19
19
 
20
- def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
20
+ def initialize(container:, call:)
21
21
  @container = container
22
22
  @call = call
23
23
  @manifest = container.manifest
24
24
  @file_store = container.file_store
25
- @evaluator = evaluator
26
- @cache = {}
27
25
  end
28
26
 
29
27
  # Returns the soonest `next_due_at` across all entries with a fetch
@@ -52,29 +50,38 @@ module Textus
52
50
  private
53
51
 
54
52
  def row_for(mentry)
55
- set = @manifest.rules.for(mentry.key)
56
- fetch = set.fetch
53
+ policy = lifecycle_for(mentry.key)
57
54
  envelope = safe_get(mentry.key)
58
55
  last = envelope&.meta&.dig("last_fetched_at")
59
56
 
60
- return base_row(mentry, last).merge(status: :no_policy) if fetch.nil?
61
-
62
- fp = fetch.to_freshness_policy
63
- cache_key = [mentry.key, last]
64
- verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
65
- status = if verdict.fresh? then :fresh
66
- elsif last.nil? then :never_fetched
67
- else :stale
68
- end
57
+ return base_row(mentry, last).merge(status: :no_policy) if policy.nil?
69
58
 
59
+ expired, reason = Textus::Domain::Lifecycle.verdict(
60
+ policy: policy,
61
+ last_fetched_at: last,
62
+ mtime: mtime_for(mentry.key),
63
+ now: @call.now,
64
+ )
70
65
  base_row(mentry, last).merge(
71
- ttl_seconds: fp.ttl_seconds,
72
- on_stale: fp.on_stale,
73
- status: status,
74
- next_due_at: next_due(last, fp.ttl_seconds),
66
+ ttl_seconds: policy.ttl_seconds,
67
+ action: policy.on_expire,
68
+ status: expired ? :expired : :fresh,
69
+ reason: reason,
70
+ next_due_at: next_due(last, policy.ttl_seconds),
75
71
  )
76
72
  end
77
73
 
74
+ def lifecycle_for(key)
75
+ @manifest.rules.for(key).lifecycle
76
+ end
77
+
78
+ def mtime_for(key)
79
+ path = @manifest.resolver.resolve(key).path
80
+ @file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
81
+ rescue Textus::Error
82
+ nil
83
+ end
84
+
78
85
  def base_row(mentry, last)
79
86
  {
80
87
  key: mentry.key,