textus 0.47.1 → 0.50.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +9 -7
  4. data/SPEC.md +44 -69
  5. data/docs/reference/conventions.md +13 -12
  6. data/lib/textus/boot.rb +47 -32
  7. data/lib/textus/builder/renderer/json.rb +1 -1
  8. data/lib/textus/cli/runner.rb +5 -4
  9. data/lib/textus/cli/verb/boot.rb +2 -1
  10. data/lib/textus/cli.rb +0 -1
  11. data/lib/textus/dispatcher.rb +3 -8
  12. data/lib/textus/doctor/check/generator_drift.rb +28 -0
  13. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
  14. data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
  15. data/lib/textus/doctor.rb +2 -0
  16. data/lib/textus/domain/lifecycle.rb +83 -0
  17. data/lib/textus/domain/policy/base_guards.rb +2 -2
  18. data/lib/textus/domain/policy/lifecycle.rb +35 -0
  19. data/lib/textus/domain/staleness.rb +6 -3
  20. data/lib/textus/envelope/io/writer.rb +2 -2
  21. data/lib/textus/hooks/context.rb +1 -1
  22. data/lib/textus/init.rb +4 -4
  23. data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
  24. data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
  25. data/lib/textus/maintenance/tend.rb +110 -0
  26. data/lib/textus/manifest/entry/base.rb +1 -0
  27. data/lib/textus/manifest/entry/derived.rb +4 -2
  28. data/lib/textus/manifest/rules.rb +11 -23
  29. data/lib/textus/manifest/schema.rb +4 -19
  30. data/lib/textus/mcp/server.rb +9 -2
  31. data/lib/textus/ports/audit_log.rb +1 -1
  32. data/lib/textus/ports/audit_subscriber.rb +1 -1
  33. data/lib/textus/ports/fetch/detached.rb +5 -1
  34. data/lib/textus/read/boot.rb +4 -2
  35. data/lib/textus/read/freshness.rb +37 -26
  36. data/lib/textus/read/get.rb +47 -32
  37. data/lib/textus/read/pulse.rb +1 -1
  38. data/lib/textus/read/rule_explain.rb +10 -16
  39. data/lib/textus/read/rule_list.rb +5 -7
  40. data/lib/textus/version.rb +1 -1
  41. data/lib/textus/write/accept.rb +1 -1
  42. data/lib/textus/write/fetch_worker.rb +8 -12
  43. data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
  44. data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
  45. data/lib/textus/write/reject.rb +1 -1
  46. metadata +8 -15
  47. data/lib/textus/cli/group/fetch.rb +0 -20
  48. data/lib/textus/cli/verb/fetch.rb +0 -14
  49. data/lib/textus/cli/verb/fetch_all.rb +0 -20
  50. data/lib/textus/domain/policy/fetch.rb +0 -37
  51. data/lib/textus/domain/policy/retention.rb +0 -26
  52. data/lib/textus/domain/retention.rb +0 -44
  53. data/lib/textus/domain/staleness/intake_check.rb +0 -54
  54. data/lib/textus/maintenance/migrate.rb +0 -65
  55. data/lib/textus/read/retainable.rb +0 -17
  56. data/lib/textus/read/stale.rb +0 -17
  57. data/lib/textus/write/fetch_all.rb +0 -53
  58. data/lib/textus/write/retention_sweep.rb +0 -64
@@ -42,6 +42,7 @@ module Textus
42
42
  # without `respond_to?` guards.
43
43
  def template = nil
44
44
  def inject_boot = false # rubocop:disable Naming/PredicateMethod
45
+ def provenance = true # rubocop:disable Naming/PredicateMethod
45
46
  def events = {}
46
47
  def publish_tree = nil
47
48
  def ignore = []
@@ -5,13 +5,14 @@ module Textus
5
5
  Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
6
6
  External = ::Data.define(:sources, :command)
7
7
 
8
- attr_reader :source, :template, :inject_boot, :events
8
+ attr_reader :source, :template, :inject_boot, :provenance, :events
9
9
 
10
- def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
10
+ def initialize(source:, template: nil, inject_boot: false, provenance: true, events: {}, **rest)
11
11
  super(**rest)
12
12
  @source = source
13
13
  @template = template
14
14
  @inject_boot = inject_boot
15
+ @provenance = provenance
15
16
  @events = events || {}
16
17
  end
17
18
 
@@ -53,6 +54,7 @@ module Textus
53
54
  source: source,
54
55
  template: raw["template"],
55
56
  inject_boot: raw["inject_boot"] == true,
57
+ provenance: raw.fetch("provenance", true) != false,
56
58
  events: raw["events"] || {},
57
59
  **common,
58
60
  )
@@ -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
@@ -25,17 +25,15 @@ module Textus
25
25
  ENTRY_KEYS = %w[
26
26
  key path zone kind schema owner nested format
27
27
  compute template publish
28
- intake events inject_boot ignore tracked
28
+ intake events inject_boot provenance ignore tracked
29
29
  ].freeze
30
30
  # ADR 0052: the typed publish block — `publish: { to: [...] }` (file
31
31
  # fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
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
 
@@ -89,12 +89,19 @@ module Textus
89
89
  return
90
90
  end
91
91
 
92
- @session.check_etag!(contract_etag)
93
-
94
92
  name = params["name"]
95
93
  args = params["arguments"] || {}
94
+
95
+ # ADR 0083: the contract-drift guard gates mutating verbs — every MCP
96
+ # verb that is NOT a pure read (Write:: + the destructive Maintenance::
97
+ # verbs tend/zone_mv/key_*_prefix). Reads and boot bypass it (a stale
98
+ # read returns on-disk truth; boot re-orients). Keying on read_verbs
99
+ # (not write_verbs) keeps the destructive Maintenance:: verbs gated.
100
+ @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
101
+
96
102
  result = Catalog.call(name, session: @session, store: @store, args: args)
97
103
  @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
104
+ @session = @session.with(contract_etag: contract_etag) if name == "boot"
98
105
 
99
106
  emit_result(rid, {
100
107
  "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
@@ -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
@@ -10,14 +10,16 @@ module Textus
10
10
  verb :boot
11
11
  summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
12
12
  surfaces :cli, :mcp
13
+ arg :lean, :boolean,
14
+ description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
13
15
 
14
16
  def initialize(container:, call:)
15
17
  @container = container
16
18
  @call = call
17
19
  end
18
20
 
19
- def call
20
- Textus::Boot.build(container: @container)
21
+ def call(lean: false)
22
+ Textus::Boot.build(container: @container, lean: lean)
21
23
  end
22
24
  end
23
25
  end
@@ -2,28 +2,30 @@ 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 scan (ADR 0079, 0085). Walks every entry declared in
6
+ # the manifest, consults `rules.for(key)` for a `lifecycle:` policy, and
7
+ # reports the unified verdict. Status is one of :fresh, :expired, or
8
+ # :no_policy; the row also carries the policy's :action (on_expire).
9
+ #
10
+ # ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
11
+ # surface. This is now a Ruby-only internal scan (empty `surfaces`, the
12
+ # honest home reserved by ADR 0073) consumed by `pulse` (which derives
13
+ # `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
14
+ # into per-entry lifecycle detail via `get` (last_fetched_at) + `rule_explain`
15
+ # (the ttl / on_expire policy) instead of a dedicated verb.
9
16
  class Freshness
10
17
  extend Textus::Contract::DSL
11
18
 
12
19
  verb :freshness
13
- summary "Report the fetch-freshness status of every entry with a fetch policy."
14
- surfaces :cli
15
- cli "freshness"
20
+ summary "Internal per-entry lifecycle scan (status, age, ttl, on_expire); backs pulse + hook context. No public surface (ADR 0085)."
16
21
  arg :prefix, String, required: false, description: "filter to keys with this prefix"
17
22
  arg :zone, String, required: false, description: "filter to entries in this zone"
18
- view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
19
23
 
20
- def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
24
+ def initialize(container:, call:)
21
25
  @container = container
22
26
  @call = call
23
27
  @manifest = container.manifest
24
28
  @file_store = container.file_store
25
- @evaluator = evaluator
26
- @cache = {}
27
29
  end
28
30
 
29
31
  # Returns the soonest `next_due_at` across all entries with a fetch
@@ -52,29 +54,38 @@ module Textus
52
54
  private
53
55
 
54
56
  def row_for(mentry)
55
- set = @manifest.rules.for(mentry.key)
56
- fetch = set.fetch
57
+ policy = lifecycle_for(mentry.key)
57
58
  envelope = safe_get(mentry.key)
58
59
  last = envelope&.meta&.dig("last_fetched_at")
59
60
 
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
61
+ return base_row(mentry, last).merge(status: :no_policy) if policy.nil?
69
62
 
63
+ expired, reason = Textus::Domain::Lifecycle.verdict(
64
+ policy: policy,
65
+ last_fetched_at: last,
66
+ mtime: mtime_for(mentry.key),
67
+ now: @call.now,
68
+ )
70
69
  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),
70
+ ttl_seconds: policy.ttl_seconds,
71
+ action: policy.on_expire,
72
+ status: expired ? :expired : :fresh,
73
+ reason: reason,
74
+ next_due_at: next_due(last, policy.ttl_seconds),
75
75
  )
76
76
  end
77
77
 
78
+ def lifecycle_for(key)
79
+ @manifest.rules.for(key).lifecycle
80
+ end
81
+
82
+ def mtime_for(key)
83
+ path = @manifest.resolver.resolve(key).path
84
+ @file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
85
+ rescue Textus::Error
86
+ nil
87
+ end
88
+
78
89
  def base_row(mentry, last)
79
90
  {
80
91
  key: mentry.key,
@@ -2,42 +2,39 @@ module Textus
2
2
  module Read
3
3
  # The one read path. `fetch:` controls behavior:
4
4
  # fetch: false (default) — pure read: the on-disk envelope annotated with
5
- # a freshness verdict. NEVER builds the orchestrator (no threads/forks/
6
- # locks/events). This is the safe default for direct (in-process)
7
- # callers — accept/reject/publish, materializer, uid, validate_all/
8
- # validator, schema tooling, and the hook context that must read
9
- # persisted truth without triggering a fetch.
10
- # fetch: trueread-through: after a stale verdict, hands off to the
11
- # fetch orchestrator per the entry's fetch rule (degrades to the pure
12
- # result when the key has no rule).
5
+ # a lifecycle freshness verdict. NEVER builds the orchestrator and NEVER
6
+ # mutates. Safe for direct callers (accept/reject/publish, materializer,
7
+ # uid, validators, hooks).
8
+ # fetch: true read-through: after a stale verdict on a `refresh` policy,
9
+ # hands off to the fetch orchestrator. A read NEVER performs a
10
+ # destructive action (drop/archive) those belong to the `tend` sweep
11
+ # (ADR 0079).
13
12
  #
14
- # The public `get` verb is read-through because the contract declares
15
- # `arg :fetch, default: true`, injected on every verb surface (RoleScope +
16
- # MCP map_args, ADR 0062 amendment). Direct construction bypasses that
17
- # injection and so gets the safe `fetch: false` method default.
13
+ # Lifecycle policy comes from the unified `lifecycle:` rule slot (ADR 0079).
18
14
  class Get
19
15
  extend Textus::Contract::DSL
20
16
 
21
17
  verb :get
22
- summary "Read one entry. Read-through by default — fetches on stale per " \
23
- "the entry's fetch rule, degrading to a pure read when the key " \
24
- "has no rule. Pass fetch:false for a guaranteed pure on-disk " \
25
- "read. Returns the envelope (uid, etag, _meta, body, freshness)."
18
+ summary "Read one entry. Read-through by default — refreshes on stale per " \
19
+ "the entry's lifecycle rule (on_expire: refresh), degrading to a " \
20
+ "pure read when the key has no rule. Pass fetch:false for a " \
21
+ "guaranteed pure on-disk read. Returns the envelope (uid, etag, " \
22
+ "_meta, body, freshness)."
26
23
  surfaces :cli, :mcp
27
24
  arg :key, String, required: true, positional: true,
28
25
  description: "dotted entry key to read, e.g. 'knowledge.project'"
29
26
  arg :fetch, :boolean, default: true,
30
- description: "read-through (fetch on stale per the " \
31
- "entry's fetch rule) when true, the default; " \
27
+ description: "read-through (refresh on stale per the " \
28
+ "entry's lifecycle rule) when true, the default; " \
32
29
  "false returns the on-disk envelope without ever fetching"
33
30
  view { |v, _i| v.to_h_for_wire }
34
31
 
35
- def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator, orchestrator: nil)
32
+ def initialize(container:, call:, orchestrator: nil, file_stat: Textus::Ports::Storage::FileStat.new)
36
33
  @container = container
37
34
  @call = call
38
35
  @manifest = container.manifest
39
36
  @file_store = container.file_store
40
- @evaluator = evaluator
37
+ @file_stat = file_stat
41
38
  @orchestrator = orchestrator # nil → built lazily on first fetch only
42
39
  end
43
40
 
@@ -46,12 +43,11 @@ module Textus
46
43
  return envelope if envelope.nil?
47
44
  return envelope unless fetch && envelope.freshness&.stale
48
45
 
49
- fetch_policy = fetch_policy_for(key)
50
- return envelope if fetch_policy.nil?
46
+ policy = lifecycle_for(key)
47
+ return envelope unless policy&.on_expire == :refresh # only refresh acts on a read
51
48
 
52
- policy = fetch_policy.to_freshness_policy
53
49
  verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
54
- outcome = orchestrator.execute(policy.decide(verdict), key: key)
50
+ outcome = orchestrator.execute(refresh_policy(policy).decide(verdict), key: key)
55
51
  resolve(outcome, envelope)
56
52
  end
57
53
 
@@ -64,23 +60,42 @@ module Textus
64
60
 
65
61
  private
66
62
 
67
- # Pure read + freshness verdict; no orchestrator dependency.
63
+ # Pure read + unified lifecycle verdict; no orchestrator dependency.
68
64
  def annotated_envelope(key)
69
65
  envelope = read_raw_envelope(key)
70
66
  return nil if envelope.nil?
71
67
 
72
- fetch_policy = fetch_policy_for(key)
73
- return annotate_fresh(envelope) if fetch_policy.nil?
68
+ policy = lifecycle_for(key)
69
+ return annotate_fresh(envelope) if policy.nil?
74
70
 
75
- policy = fetch_policy.to_freshness_policy
76
- verdict = @evaluator.call(policy, envelope, now: @call.now)
71
+ expired, reason = Textus::Domain::Lifecycle.verdict(
72
+ policy: policy,
73
+ last_fetched_at: envelope.meta&.dig("last_fetched_at"),
74
+ mtime: mtime_for(key),
75
+ now: @call.now,
76
+ )
77
77
  envelope.with(freshness: Textus::Domain::Freshness.build(
78
- stale: verdict.stale?, reason: verdict.reason, fetching: false,
78
+ stale: expired, reason: reason, fetching: false,
79
79
  ))
80
80
  end
81
81
 
82
- def fetch_policy_for(key)
83
- @manifest.rules.for(key).fetch
82
+ def lifecycle_for(key)
83
+ @manifest.rules.for(key).lifecycle
84
+ end
85
+
86
+ def refresh_policy(policy)
87
+ Textus::Domain::Freshness::Policy.new(
88
+ ttl_seconds: policy.ttl_seconds,
89
+ on_stale: policy.budget_ms ? :timed_sync : :sync,
90
+ sync_budget_ms: policy.budget_ms,
91
+ )
92
+ end
93
+
94
+ def mtime_for(key)
95
+ path = @manifest.resolver.resolve(key).path
96
+ @file_stat.exists?(path) ? @file_stat.mtime(path) : nil
97
+ rescue Textus::Error
98
+ nil
84
99
  end
85
100
 
86
101
  def resolve(outcome, envelope)
@@ -30,7 +30,7 @@ module Textus
30
30
  {
31
31
  "cursor" => @audit_log.latest_seq,
32
32
  "changed" => audit_changes_since(since),
33
- "stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
33
+ "stale" => freshness_rows.select { |r| r[:status] == :expired }.map { |r| r[:key] },
34
34
  "pending_review" => review_keys,
35
35
  "doctor" => doctor_summary,
36
36
  "contract_etag" => contract_etag,
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Read
3
3
  # Effective rules for a key, at two depths (ADR 0059). Lean by default —
4
- # `{ fetch, guard }`, the agent-cheap read that was the `rules` verb. With
4
+ # `{ lifecycle, guard }`, the agent-cheap read that was the `rules` verb. With
5
5
  # `detail: true` it returns the verbose explanation — every matching policy
6
6
  # block plus the per-transition guard predicate names — that was
7
7
  # `policy_explain`. One verb, one name across CLI/MCP/method; the audience
@@ -10,7 +10,7 @@ module Textus
10
10
  extend Textus::Contract::DSL
11
11
 
12
12
  verb :rule_explain
13
- summary "Effective rules for a key. Lean {fetch, guard} by default; detail: true adds matched blocks + guard predicates."
13
+ summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
14
14
  surfaces :cli, :mcp
15
15
  cli "rule explain"
16
16
  arg :key, String, required: true, positional: true,
@@ -34,11 +34,10 @@ module Textus
34
34
  def effective(key)
35
35
  set = @manifest.rules.for(key)
36
36
  {
37
- "fetch" => set.fetch && {
38
- "ttl_seconds" => set.fetch.ttl_seconds,
39
- "on_stale" => set.fetch.on_stale,
40
- "sync_budget_ms" => set.fetch.sync_budget_ms,
41
- "fetch_timeout_seconds" => set.fetch.fetch_timeout_seconds,
37
+ "lifecycle" => set.lifecycle && {
38
+ "ttl_seconds" => set.lifecycle.ttl_seconds,
39
+ "on_expire" => set.lifecycle.on_expire,
40
+ "budget_ms" => set.lifecycle.budget_ms,
42
41
  },
43
42
  "guard" => set.guard,
44
43
  }.compact
@@ -57,22 +56,17 @@ module Textus
57
56
  matched_blocks: matching.map do |b|
58
57
  {
59
58
  match: b.match,
60
- fetch: !b.fetch.nil?,
59
+ lifecycle: !b.lifecycle.nil?,
61
60
  handler_allowlist: !b.handler_allowlist.nil?,
62
61
  guard: !b.guard.nil?,
63
- retention: !b.retention.nil?,
64
62
  }
65
63
  end,
66
64
  effective: {
67
- fetch: winners.fetch && {
68
- ttl_seconds: winners.fetch.ttl_seconds,
69
- on_stale: winners.fetch.on_stale,
65
+ lifecycle: winners.lifecycle && {
66
+ ttl_seconds: winners.lifecycle.ttl_seconds,
67
+ on_expire: winners.lifecycle.on_expire,
70
68
  },
71
69
  handler_allowlist: winners.handler_allowlist&.handlers,
72
- retention: winners.retention && {
73
- expire_after: winners.retention.expire_after,
74
- archive_after: winners.retention.archive_after,
75
- },
76
70
  },
77
71
  guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
78
72
  [transition, factory.for(transition, key).predicates.map(&:name)]
@@ -20,17 +20,15 @@ module Textus
20
20
  def call
21
21
  @manifest.rules.blocks.map do |b|
22
22
  row = { "match" => b.match }
23
- if b.fetch
24
- row["fetch"] = {
25
- "ttl_seconds" => b.fetch.ttl_seconds,
26
- "on_stale" => b.fetch.on_stale,
27
- "sync_budget_ms" => b.fetch.sync_budget_ms,
28
- "fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
23
+ if b.lifecycle
24
+ row["lifecycle"] = {
25
+ "ttl_seconds" => b.lifecycle.ttl_seconds,
26
+ "on_expire" => b.lifecycle.on_expire,
27
+ "budget_ms" => b.lifecycle.budget_ms,
29
28
  }
30
29
  end
31
30
  row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
32
31
  row["guard"] = b.guard if b.guard
33
- row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
34
32
  row
35
33
  end
36
34
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.47.1"
2
+ VERSION = "0.50.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -52,7 +52,7 @@ module Textus
52
52
 
53
53
  def hook_context = @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
54
54
  def put_op = @put_op ||= Textus::Write::Put.new(container: @container, call: @call)
55
- def delete_op = @delete_op ||= Textus::Write::Delete.new(container: @container, call: @call)
55
+ def delete_op = @delete_op ||= Textus::Write::KeyDelete.new(container: @container, call: @call)
56
56
  end
57
57
  end
58
58
  end
@@ -2,16 +2,10 @@ require "timeout"
2
2
 
3
3
  module Textus
4
4
  module Write
5
+ # Internal fetch executor for one quarantine/intake entry. No longer a
6
+ # public verb (ADR 0079 collapsed the `fetch` surface): used by `get`'s
7
+ # orchestrator (read-through refresh) and by the `tend` sweep.
5
8
  class FetchWorker
6
- extend Textus::Contract::DSL
7
-
8
- verb :fetch
9
- summary "Run a fetch action for one quarantine entry."
10
- surfaces :cli, :mcp
11
- arg :key, String, required: true, positional: true,
12
- description: "quarantine-zone entry key to refresh using its declared intake action"
13
- view { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
14
-
15
9
  FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
16
10
 
17
11
  def initialize(container:, call:)
@@ -69,9 +63,11 @@ module Textus
69
63
  @fetch_events ||= FetchEvents.from(container: @container, call: @call)
70
64
  end
71
65
 
72
- def fetch_timeout_for(key)
73
- rule = @manifest.rules.for(key)
74
- rule&.fetch&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
66
+ # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
67
+ # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
68
+ # applies to every intake.
69
+ def fetch_timeout_for(_key)
70
+ FETCH_TIMEOUT_SECONDS
75
71
  end
76
72
 
77
73
  def fetch_with_events(key, mentry, remaining)
@@ -1,9 +1,9 @@
1
1
  module Textus
2
2
  module Write
3
- class Delete
3
+ class KeyDelete
4
4
  extend Textus::Contract::DSL
5
5
 
6
- verb :delete
6
+ verb :key_delete
7
7
  summary "Delete one entry by key. Single-key, lower blast radius than " \
8
8
  "key_delete_prefix; guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
9
9
  surfaces :cli, :mcp
@@ -25,7 +25,7 @@ module Textus
25
25
  Textus::Manifest::Data.validate_key!(key)
26
26
  mentry = @manifest.resolver.resolve(key).entry
27
27
 
28
- guard_for(:delete, key, if_etag: if_etag).check!(eval_for(:delete, target_key: key))
28
+ guard_for(:key_delete, key, if_etag: if_etag).check!(eval_for(:key_delete, target_key: key))
29
29
 
30
30
  writer.delete(key, mentry: mentry, if_etag: if_etag)
31
31