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
data/lib/textus/boot.rb CHANGED
@@ -71,49 +71,50 @@ module Textus
71
71
  },
72
72
  }.freeze
73
73
 
74
- # Curated agent-facing verb catalog. For verbs that have a Dispatcher contract,
75
- # the summary is derived from `contract.summary` at load time (ADR 0039). The
76
- # editorial strings below are the fallback for CLI-only verbs without contracts.
77
- # CLI_VERBS itself is assigned in textus.rb after Zeitwerk eager_load so that
78
- # all contract-declaring files are loaded before derivation runs.
74
+ # Curated agent-facing verb catalog. This declares which verbs the operator
75
+ # CLI surfaces and in what order the editorial presentation. The summary of
76
+ # each verb is a fact, not presentation: it is derived from `contract.summary`
77
+ # at load time (ADR 0039). A literal "summary" survives here only for grouped
78
+ # CLI tokens (schema/key/rule/hook) that aggregate several sub-contracts and so
79
+ # have no single contract to derive from. CLI_VERBS itself is assigned in
80
+ # textus.rb after Zeitwerk eager_load so all contract files are present.
79
81
  CURATED_CLI_VERBS = [
80
82
  { "name" => "boot" },
81
83
  { "name" => "list" },
82
84
  { "name" => "get" },
83
- { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
85
+ { "name" => "where" },
84
86
  { "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
85
87
  { "name" => "put" },
86
88
  { "name" => "propose" },
87
- { "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
88
- { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
89
- { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_tree fan out copies" },
90
- { "name" => "fetch" },
91
- { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
92
- { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
93
- { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
94
- { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
95
- { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
96
- { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
89
+ { "name" => "accept" },
90
+ { "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
91
+ { "name" => "build" },
92
+ { "name" => "tend" },
93
+ { "name" => "audit" },
94
+ { "name" => "blame" },
95
+ { "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
96
+ { "name" => "doctor" },
97
+ { "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
97
98
  { "name" => "pulse" },
98
99
  { "name" => "capabilities" },
99
100
  ].freeze
100
101
 
101
- # Build the CLI verb catalog by deriving each summary from the corresponding
102
- # Dispatcher contract when one exists, falling back to the editorial string for
103
- # CLI-only verbs without a contract (e.g. accept, build, where). Called once
104
- # from textus.rb after eager_load so all contract files are present.
105
- def self.build_cli_verbs
106
- by_contract = Dispatcher::VERBS.values
107
- .select { |k| k.respond_to?(:contract?) && k.contract? }
108
- .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
102
+ # verb token => contract.summary, for every Dispatcher verb that carries a
103
+ # contract. The single source for a verb's one-line summary (ADR 0039).
104
+ def self.contract_summaries
105
+ Dispatcher::VERBS.values
106
+ .select { |k| k.respond_to?(:contract?) && k.contract? }
107
+ .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
108
+ end
109
109
 
110
+ # Build the CLI verb catalog: each summary is derived from its contract when
111
+ # one exists, falling back to the curated editorial string for grouped tokens
112
+ # (schema/key/rule/hook). Called once from textus.rb after eager_load.
113
+ def self.build_cli_verbs
114
+ summaries = contract_summaries
110
115
  CURATED_CLI_VERBS.map do |entry|
111
- derived = by_contract[entry["name"]]
112
- if derived
113
- entry.merge("summary" => derived)
114
- else
115
- entry
116
- end
116
+ derived = summaries[entry["name"]]
117
+ derived ? entry.merge("summary" => derived) : entry
117
118
  end
118
119
  end
119
120
 
@@ -130,7 +131,8 @@ module Textus
130
131
  # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
131
132
  # agent's real read and write surface, named as verbs the agent calls —
132
133
  # not CLI strings. read_verbs can neither advertise a verb the agent
133
- # cannot call (audit/freshness/doctor are CLI-only) nor omit one it can
134
+ # cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
135
+ # internal scan, ADR 0085) nor omit one it can
134
136
  # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
135
137
  # framing (role is connection-resolved over MCP; there is no stdin).
136
138
  # writable_zones / propose_zone below carry the agent's write authority.
@@ -196,8 +198,20 @@ module Textus
196
198
  )
197
199
  end
198
200
 
199
- def self.build(container:)
201
+ def self.build(container:, lean: false)
200
202
  manifest = container.manifest
203
+ etag = Textus::Etag.for_contract(container.root)
204
+
205
+ if lean
206
+ return {
207
+ "protocol" => PROTOCOL_ID,
208
+ "store_root" => container.root,
209
+ "zones" => zones_for(manifest),
210
+ "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
211
+ "contract_etag" => etag,
212
+ }
213
+ end
214
+
201
215
  {
202
216
  "protocol" => PROTOCOL_ID,
203
217
  "store_root" => container.root,
@@ -208,6 +222,7 @@ module Textus
208
222
  "cli_verbs" => CLI_VERBS.map(&:dup),
209
223
  "agent_protocol" => agent_protocol(manifest),
210
224
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
225
+ "contract_etag" => etag,
211
226
  "docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
212
227
  }
213
228
  end
@@ -6,7 +6,7 @@ module Textus
6
6
  class Json < Renderer
7
7
  def call(mentry:, data:)
8
8
  content = mentry.template ? parse_rendered_template!(mentry, data) : default_shape(mentry, data)
9
- final = InjectMeta.call(content, mentry)
9
+ final = mentry.provenance ? InjectMeta.call(content, mentry) : content
10
10
  Entry.for_format("json").serialize(meta: {}, body: "", content: final)
11
11
  end
12
12
 
@@ -137,10 +137,11 @@ module Textus
137
137
  BEHAVIORAL_HATCHES = %i[get put build].freeze
138
138
 
139
139
  # Contract verbs whose CLI is a plain `< Verb` command, not a projection at
140
- # all — worker verbs and composite reports assembled outside the contract:
141
- # fetch, fetch_allbackground intake workers (not request/response)
142
- # boot, doctor — composite reports
143
- NON_PROJECTED_CLI = %i[fetch fetch_all boot doctor].freeze
140
+ # all — composite reports assembled outside the contract:
141
+ # boot, doctorcomposite reports
142
+ # (fetch/fetch_all were removed in ADR 0079: FetchWorker is now internal,
143
+ # driven by get's read-through orchestrator and the tend sweep.)
144
+ NON_PROJECTED_CLI = %i[boot doctor].freeze
144
145
 
145
146
  # The installer skips generation for either category.
146
147
  HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
@@ -3,9 +3,10 @@ module Textus
3
3
  class Verb
4
4
  class Boot < Verb
5
5
  command_name "boot"
6
+ option :lean, "--lean"
6
7
 
7
8
  def call(store)
8
- emit(store.boot)
9
+ emit(store.boot(lean: !!lean))
9
10
  end
10
11
  end
11
12
  end
data/lib/textus/cli.rb CHANGED
@@ -123,7 +123,6 @@ module Textus
123
123
  textus where KEY
124
124
  textus get KEY
125
125
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
126
- textus freshness [--prefix=KEY] [--zone=Z]
127
126
  textus fetch KEY
128
127
  textus fetch all [--prefix=KEY] [--zone=Z]
129
128
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
@@ -7,14 +7,11 @@ module Textus
7
7
  # Write
8
8
  put: Textus::Write::Put,
9
9
  propose: Textus::Write::Propose,
10
- delete: Textus::Write::Delete,
11
- mv: Textus::Write::Mv,
10
+ key_delete: Textus::Write::KeyDelete,
11
+ key_mv: Textus::Write::KeyMv,
12
12
  accept: Textus::Write::Accept,
13
13
  reject: Textus::Write::Reject,
14
14
  build: Textus::Write::Build,
15
- fetch: Textus::Write::FetchWorker,
16
- fetch_all: Textus::Write::FetchAll,
17
- retain: Textus::Write::RetentionSweep,
18
15
 
19
16
  # Read
20
17
  get: Textus::Read::Get,
@@ -24,7 +21,6 @@ module Textus
24
21
  blame: Textus::Read::Blame,
25
22
  audit: Textus::Read::Audit,
26
23
  freshness: Textus::Read::Freshness,
27
- stale: Textus::Read::Stale,
28
24
  deps: Textus::Read::Deps,
29
25
  rdeps: Textus::Read::Rdeps,
30
26
  pulse: Textus::Read::Pulse,
@@ -35,14 +31,13 @@ module Textus
35
31
  validate_all: Textus::Read::ValidateAll,
36
32
  doctor: Textus::Read::Doctor,
37
33
  boot: Textus::Read::Boot,
38
- retainable: Textus::Read::Retainable,
39
34
  capabilities: Textus::Read::Capabilities,
40
35
 
41
36
  # Maintenance
42
- migrate: Textus::Maintenance::Migrate,
43
37
  zone_mv: Textus::Maintenance::ZoneMv,
44
38
  key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
45
39
  key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
40
+ tend: Textus::Maintenance::Tend,
46
41
  rule_lint: Textus::Maintenance::RuleLint,
47
42
  }.freeze
48
43
 
@@ -0,0 +1,28 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # ADR 0079: generator/build drift — a derived/external entry whose sources
5
+ # changed since its generated.at. Dependency-based (not age-based), so it
6
+ # stays OUT of the lifecycle/freshness unification and lives here as a
7
+ # health signal. This is the surviving home for what the removed `stale`
8
+ # verb reported.
9
+ class GeneratorDrift < Check
10
+ def call
11
+ gen = Textus::Domain::Staleness::GeneratorCheck.new(
12
+ manifest: manifest,
13
+ file_stat: Textus::Ports::Storage::FileStat.new,
14
+ )
15
+ manifest.data.entries.flat_map { |m| gen.rows_for(m) }.map do |row|
16
+ {
17
+ "code" => "generator_drift",
18
+ "level" => "warning",
19
+ "subject" => row["key"],
20
+ "message" => row["reason"],
21
+ "fix" => "rebuild the entry: `textus build`",
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # ADR 0079: refresh is valid only for intake entries; drop/archive are
5
+ # invalid for intake entries (they would re-fetch, not prune).
6
+ class LifecycleActionInvalid < Check
7
+ def call
8
+ manifest.data.entries.filter_map do |mentry|
9
+ policy = manifest.rules.for(mentry.key).lifecycle
10
+ next if policy.nil?
11
+
12
+ intake = mentry.is_a?(Textus::Manifest::Entry::Intake)
13
+ bad = (policy.on_expire == :refresh && !intake) || (policy.destructive? && intake)
14
+ next unless bad
15
+
16
+ issue_for(mentry, policy, intake)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def issue_for(mentry, policy, intake)
23
+ {
24
+ "code" => "lifecycle.action_invalid",
25
+ "level" => "error",
26
+ "subject" => mentry.key,
27
+ "message" => "on_expire: #{policy.on_expire} is not valid for a " \
28
+ "#{intake ? "intake" : "stored"} entry",
29
+ "fix" => if intake
30
+ "use on_expire: refresh|warn for intake entries"
31
+ else
32
+ "use on_expire: drop|archive|warn for stored entries"
33
+ end,
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -6,7 +6,7 @@ module Textus
6
6
  # guard). 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
9
- SLOTS = %i[fetch handler_allowlist guard].freeze
9
+ SLOTS = %i[lifecycle handler_allowlist guard].freeze
10
10
 
11
11
  def call
12
12
  out = []
data/lib/textus/doctor.rb CHANGED
@@ -27,6 +27,8 @@ module Textus
27
27
  Check::OrphanedPublishTargets,
28
28
  Check::PublishTreeIndexOverlap,
29
29
  Check::ProposalTargets,
30
+ Check::LifecycleActionInvalid,
31
+ Check::GeneratorDrift,
30
32
  ].freeze
31
33
 
32
34
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
@@ -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