textus 0.22.0 → 0.26.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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +102 -0
  4. data/README.md +1 -1
  5. data/SPEC.md +12 -12
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/boot.rb +27 -29
  48. data/lib/textus/builder/pipeline.rb +3 -3
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +2 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +1 -1
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +1 -1
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/doctor/check/audit_log.rb +2 -2
  83. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  84. data/lib/textus/doctor/check/hooks.rb +4 -3
  85. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  86. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  87. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  88. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  89. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  90. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  91. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  92. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  93. data/lib/textus/doctor/check/schemas.rb +2 -2
  94. data/lib/textus/doctor/check/sentinels.rb +2 -2
  95. data/lib/textus/doctor/check/templates.rb +2 -2
  96. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  97. data/lib/textus/doctor/check.rb +5 -3
  98. data/lib/textus/doctor.rb +24 -27
  99. data/lib/textus/domain/authorizer.rb +4 -4
  100. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  101. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  102. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  103. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  104. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  105. data/lib/textus/domain/staleness.rb +1 -1
  106. data/lib/textus/hooks/builtin.rb +14 -14
  107. data/lib/textus/hooks/context.rb +13 -13
  108. data/lib/textus/hooks/error_log.rb +32 -0
  109. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  110. data/lib/textus/hooks/loader.rb +29 -3
  111. data/lib/textus/hooks/rpc_registry.rb +77 -0
  112. data/lib/textus/infra/audit_subscriber.rb +6 -7
  113. data/lib/textus/infra/refresh/detached.rb +1 -1
  114. data/lib/textus/key/path.rb +7 -3
  115. data/lib/textus/manifest/data.rb +78 -0
  116. data/lib/textus/manifest/entry/base.rb +4 -4
  117. data/lib/textus/manifest/entry/derived.rb +4 -5
  118. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  119. data/lib/textus/manifest/policy.rb +48 -0
  120. data/lib/textus/manifest/resolver.rb +14 -14
  121. data/lib/textus/manifest/rules.rb +1 -1
  122. data/lib/textus/manifest.rb +53 -111
  123. data/lib/textus/mcp/errors.rb +32 -0
  124. data/lib/textus/mcp/server.rb +127 -0
  125. data/lib/textus/mcp/session.rb +31 -0
  126. data/lib/textus/mcp/tool_schemas.rb +71 -0
  127. data/lib/textus/mcp/tools.rb +129 -0
  128. data/lib/textus/mcp.rb +6 -0
  129. data/lib/textus/schema/tools.rb +14 -10
  130. data/lib/textus/session.rb +84 -0
  131. data/lib/textus/store.rb +14 -9
  132. data/lib/textus/version.rb +1 -1
  133. data/lib/textus.rb +8 -1
  134. metadata +61 -36
  135. data/lib/textus/application/reads/audit.rb +0 -94
  136. data/lib/textus/application/reads/blame.rb +0 -82
  137. data/lib/textus/application/reads/deps.rb +0 -26
  138. data/lib/textus/application/reads/freshness.rb +0 -88
  139. data/lib/textus/application/reads/get.rb +0 -67
  140. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  141. data/lib/textus/application/reads/list.rb +0 -17
  142. data/lib/textus/application/reads/policy_explain.rb +0 -39
  143. data/lib/textus/application/reads/published.rb +0 -17
  144. data/lib/textus/application/reads/pulse.rb +0 -63
  145. data/lib/textus/application/reads/rdeps.rb +0 -27
  146. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  147. data/lib/textus/application/reads/stale.rb +0 -15
  148. data/lib/textus/application/reads/uid.rb +0 -23
  149. data/lib/textus/application/reads/validate_all.rb +0 -24
  150. data/lib/textus/application/reads/where.rb +0 -18
  151. data/lib/textus/application/refresh/all.rb +0 -52
  152. data/lib/textus/application/refresh/worker.rb +0 -116
  153. data/lib/textus/application/writes/accept.rb +0 -89
  154. data/lib/textus/application/writes/delete.rb +0 -33
  155. data/lib/textus/application/writes/mv.rb +0 -105
  156. data/lib/textus/application/writes/publish.rb +0 -81
  157. data/lib/textus/application/writes/put.rb +0 -37
  158. data/lib/textus/application/writes/reject.rb +0 -50
  159. data/lib/textus/infra/event_bus.rb +0 -27
  160. data/lib/textus/operations.rb +0 -176
@@ -2,55 +2,37 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Application
5
- module Writes
6
- # Owns the write pipeline (validate, serialize, etag-check, write, audit)
7
- # extracted from Store::Writer. Talks to ports (FileStore, Schemas,
8
- # AuditLog, Manifest) instead of File/FileUtils and Store directly.
5
+ module Envelope
6
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
+ # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
+ # Reader for the existing-uid lookup.
9
+ #
10
+ # Invariant: every public method's final action is @audit_log.append(...).
9
11
  #
10
12
  # No permission check, no event firing — those belong to the caller
11
- # (Application::Writes::Put / ::Delete / ::Mv).
12
- class EnvelopeIO
13
+ # (Application::Write::Put / ::Delete / ::Mv).
14
+ class Writer
13
15
  Payload = Data.define(:meta, :body, :content)
14
16
 
15
- def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
17
+ def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:, reader:)
16
18
  @file_store = file_store
17
19
  @manifest = manifest
18
20
  @schemas = schemas
19
21
  @audit_log = audit_log
20
22
  @ctx = ctx
23
+ @reader = reader
21
24
  end
22
25
 
23
- def exists?(path) = @file_store.exists?(path)
24
-
25
- # Reads an envelope by key, returning nil when absent. Used by Mv
26
- # to inspect pre-move state (UID presence, content surfacing) so
27
- # the move pipeline can consolidate I/O in one place.
28
- def read_envelope(key)
29
- res = @manifest.resolver.resolve(key)
30
- path = res.path
31
- return nil unless @file_store.exists?(path)
32
-
33
- mentry = res.entry
34
- raw = @file_store.read(path)
35
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
36
- Envelope.build(
37
- key: key, mentry: mentry, path: path,
38
- meta: parsed["_meta"], body: parsed["body"],
39
- etag: Etag.for_bytes(raw), content: parsed["content"]
40
- )
41
- end
42
-
43
- def write(key, mentry:, payload:, if_etag: nil)
26
+ def put(key, mentry:, payload:, if_etag: nil)
44
27
  path = @manifest.resolver.resolve(key).path
45
28
 
46
29
  meta = payload.meta || {}
47
- strategy = Entry.for_format(mentry.format)
48
30
 
49
- existing_uid = existing_uid_for(mentry, path)
31
+ existing_uid = @reader.existing_uid(key)
50
32
  meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
51
33
 
52
34
  bytes, eff_meta, eff_body, eff_content = serialize_for_put(
53
- mentry: mentry, path: path, strategy: strategy,
35
+ mentry: mentry, path: path,
54
36
  meta: meta, body: payload.body, content: content
55
37
  )
56
38
 
@@ -69,19 +51,22 @@ module Textus
69
51
 
70
52
  @file_store.write(path, bytes)
71
53
  etag_after = Etag.for_bytes(bytes)
54
+ envelope = Textus::Envelope.build(
55
+ key: key, mentry: mentry, path: path,
56
+ meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
57
+ )
72
58
  @audit_log.append(
73
59
  role: @ctx.role, verb: "put", key: key,
74
60
  etag_before: etag_before, etag_after: etag_after,
75
61
  extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
76
62
  )
77
- Envelope.build(
78
- key: key, mentry: mentry, path: path,
79
- meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
80
- )
63
+ envelope
81
64
  end
82
65
 
83
- def delete(key, mentry:, if_etag: nil)
84
- _ = mentry
66
+ def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
67
+ # `mentry:` is accepted for symmetry with `put` / `move` and to
68
+ # leave room for future format-specific delete hooks; no field
69
+ # on it is needed today.
85
70
  path = @manifest.resolver.resolve(key).path
86
71
  raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
87
72
 
@@ -112,7 +97,7 @@ module Textus
112
97
 
113
98
  raw = @file_store.read(to_path)
114
99
  parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
115
- envelope = Envelope.build(
100
+ envelope = Textus::Envelope.build(
116
101
  key: to_key, mentry: new_mentry, path: to_path,
117
102
  meta: parsed["_meta"], body: parsed["body"],
118
103
  etag: etag_after, content: parsed["content"]
@@ -136,16 +121,6 @@ module Textus
136
121
 
137
122
  private
138
123
 
139
- def existing_uid_for(mentry, path)
140
- return nil unless @file_store.exists?(path)
141
-
142
- raw = @file_store.read(path)
143
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
144
- Envelope.extract_uid(parsed["_meta"])
145
- rescue StandardError
146
- nil
147
- end
148
-
149
124
  def ensure_uid(format, meta, content, existing_uid)
150
125
  Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
151
126
  end
@@ -154,8 +129,7 @@ module Textus
154
129
  Textus::Entry.for_format(format).enforce_name_match!(path, meta)
155
130
  end
156
131
 
157
- def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
158
- _ = strategy
132
+ def serialize_for_put(mentry:, path:, meta:, body:, content:)
159
133
  Textus::Entry.for_format(mentry.format).serialize_for_put(
160
134
  meta: meta, body: body, content: content, path: path,
161
135
  )
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Application
3
+ module Maintenance
4
+ # Bulk-delete every leaf key under `prefix`.
5
+ module KeyDeletePrefix
6
+ def self.call(*, session:, ctx:, caps:, **)
7
+ Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
8
+ end
9
+
10
+ class Impl
11
+ def initialize(ctx:, caps:, session:)
12
+ @ctx = ctx
13
+ @caps = caps
14
+ @session = session
15
+ end
16
+
17
+ def call(prefix:, dry_run: false)
18
+ raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
19
+
20
+ leaves = Read::List::Impl.new(caps: @caps)
21
+ .call(prefix: prefix)
22
+ .map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
23
+
24
+ warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
25
+ steps = leaves.map { |k| { "op" => "delete", "key" => k } }
26
+
27
+ plan = Plan.new(steps: steps, warnings: warnings)
28
+ return plan if dry_run
29
+
30
+ steps.each do |s|
31
+ Textus::Application::Write::Delete.call(
32
+ s["key"],
33
+ session: @session, ctx: @ctx, caps: @session.write_caps,
34
+ )
35
+ end
36
+ plan
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ Textus::Application::UseCase.register(:key_delete_prefix, Textus::Application::Maintenance::KeyDeletePrefix, caps: :write)
@@ -0,0 +1,57 @@
1
+ module Textus
2
+ module Application
3
+ module Maintenance
4
+ # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
5
+ # Calls Write::Mv directly for each entry — emits one audit row per file moved.
6
+ module KeyMvPrefix
7
+ def self.call(*, session:, ctx:, caps:, **)
8
+ Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
9
+ end
10
+
11
+ class Impl
12
+ def initialize(ctx:, caps:, session:)
13
+ @ctx = ctx
14
+ @caps = caps
15
+ @session = session
16
+ end
17
+
18
+ def call(from_prefix:, to_prefix:, dry_run: false)
19
+ raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
20
+
21
+ leaves = list_leaves_under(from_prefix)
22
+ warnings = []
23
+ warnings << "no keys under #{from_prefix}" if leaves.empty?
24
+
25
+ steps = leaves.map do |old_key|
26
+ tail = old_key.delete_prefix("#{from_prefix}.")
27
+ new_key = "#{to_prefix}.#{tail}"
28
+ { "op" => "mv", "from" => old_key, "to" => new_key }
29
+ end
30
+
31
+ plan = Plan.new(steps: steps, warnings: warnings)
32
+ return plan if dry_run
33
+
34
+ steps.each do |s|
35
+ Textus::Application::Write::Mv.call(
36
+ s["from"], s["to"],
37
+ session: @session, ctx: @ctx, caps: @session.write_caps,
38
+ dry_run: false
39
+ )
40
+ end
41
+ plan
42
+ end
43
+
44
+ private
45
+
46
+ def list_leaves_under(prefix)
47
+ Read::List::Impl.new(caps: @caps)
48
+ .call(prefix: prefix)
49
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ Textus::Application::UseCase.register(:key_mv_prefix, Textus::Application::Maintenance::KeyMvPrefix, caps: :write)
@@ -0,0 +1,59 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Application
5
+ module Maintenance
6
+ # Loads a YAML migration plan and dispatches each op to the
7
+ # appropriate Maintenance use case. Concatenates resulting Plans.
8
+ module Migrate
9
+ def self.call(*, session:, ctx:, caps:, **)
10
+ Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
11
+ end
12
+
13
+ class Impl
14
+ def initialize(ctx:, caps:, session:)
15
+ @ctx = ctx
16
+ @caps = caps
17
+ @session = session
18
+ end
19
+
20
+ def call(plan_yaml:, dry_run: false)
21
+ raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
22
+ raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
23
+
24
+ ops = Array(raw["operations"])
25
+ all_steps = []
26
+ warnings = []
27
+
28
+ ops.each do |op_hash|
29
+ op_name = op_hash["op"]
30
+ sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
31
+ all_steps.concat(sub_plan.steps)
32
+ warnings.concat(sub_plan.warnings)
33
+ end
34
+
35
+ Plan.new(steps: all_steps, warnings: warnings)
36
+ end
37
+
38
+ private
39
+
40
+ def invoke_op(op_name, op_hash, dry_run:)
41
+ kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
42
+ case op_name
43
+ when "key_mv_prefix"
44
+ KeyMvPrefix.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
45
+ when "key_delete_prefix"
46
+ KeyDeletePrefix.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
47
+ when "zone_mv"
48
+ ZoneMv.call(session: @session, ctx: @ctx, caps: @caps, **kwargs)
49
+ else
50
+ raise UsageError.new("unknown op: #{op_name}")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ Textus::Application::UseCase.register(:migrate, Textus::Application::Maintenance::Migrate, caps: :write)
@@ -0,0 +1,65 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Application
5
+ module Maintenance
6
+ # Compare the live manifest's `rules:` block against a candidate
7
+ # YAML string. Returns a Plan describing rule additions/removals/
8
+ # changes. Does NOT write anything.
9
+ module RuleLint
10
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
11
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:)
16
+ @ctx = ctx
17
+ @root = caps.root
18
+ end
19
+
20
+ def call(candidate_yaml:)
21
+ live_rules = current_rules
22
+ candidate_rules = parse_candidate(candidate_yaml)
23
+
24
+ live_by_match = live_rules.to_h { |r| [r["match"], r] }
25
+ candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
26
+
27
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
28
+ { "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
29
+ end
30
+ (live_by_match.keys - candidate_by_match.keys).each do |m|
31
+ steps << { "op" => "remove_rule", "match" => m }
32
+ end
33
+ (live_by_match.keys & candidate_by_match.keys).each do |m|
34
+ next if live_by_match[m] == candidate_by_match[m]
35
+
36
+ steps << { "op" => "change_rule", "match" => m,
37
+ "from" => live_by_match[m], "to" => candidate_by_match[m] }
38
+ end
39
+
40
+ Plan.new(steps: steps, warnings: [])
41
+ end
42
+
43
+ private
44
+
45
+ def current_rules
46
+ raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
47
+ permitted_classes: [Symbol], aliases: false)
48
+ Array(raw["rules"])
49
+ end
50
+
51
+ def parse_candidate(yaml_text)
52
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
53
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
54
+
55
+ Array(raw["rules"])
56
+ rescue Psych::Exception => e
57
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ Textus::Application::UseCase.register(:rule_lint, Textus::Application::Maintenance::RuleLint, caps: :read)
@@ -0,0 +1,60 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Application
5
+ module Maintenance
6
+ # Rename a zone — rewrites the manifest's zones[] entry, rewrites
7
+ # the `zone:` field on every entry under the old zone, and moves
8
+ # every file from zones/<old>/ to zones/<new>/.
9
+ module ZoneMv
10
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
11
+ Impl.new(ctx: ctx, caps: caps).call(*, **)
12
+ end
13
+
14
+ class Impl
15
+ def initialize(ctx:, caps:)
16
+ @ctx = ctx
17
+ @manifest = caps.manifest
18
+ @root = caps.root
19
+ end
20
+
21
+ def call(from:, to:, dry_run: false)
22
+ raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
23
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
24
+
25
+ dest_dir = File.join(@root, "zones", to)
26
+ raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
27
+
28
+ affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
29
+
30
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
31
+ steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
32
+
33
+ plan = Plan.new(steps: steps, warnings: [])
34
+ return plan if dry_run
35
+
36
+ rewrite_manifest!(from, to)
37
+ FileUtils.mv(File.join(@root, "zones", from), dest_dir)
38
+ plan
39
+ end
40
+
41
+ private
42
+
43
+ def rewrite_manifest!(from, to)
44
+ path = File.join(@root, "manifest.yaml")
45
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
46
+ raw["zones"].each { |z| z["name"] = to if z["name"] == from }
47
+ raw["entries"].each do |e|
48
+ e["zone"] = to if e["zone"] == from
49
+ e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
50
+ e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
51
+ end
52
+ File.write(path, YAML.dump(raw))
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ Textus::Application::UseCase.register(:zone_mv, Textus::Application::Maintenance::ZoneMv, caps: :write)
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Application
3
+ # Bulk and structural changes to a textus store. Each use case returns
4
+ # a Plan when called with dry_run: true, and applies the plan when
5
+ # called with dry_run: false.
6
+ module Maintenance
7
+ # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
8
+ # use case knows how to apply. Warnings are strings surfaced to
9
+ # the operator (skipped keys, ambiguities).
10
+ Plan = Data.define(:steps, :warnings) do
11
+ def to_h
12
+ { "steps" => steps, "warnings" => warnings }
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -11,14 +11,14 @@ module Textus
11
11
  # semantics: pure read (`ops.get`) for materialization paths;
12
12
  # `ops.get_or_refresh` if you want refresh-on-stale.
13
13
  # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
14
- # `transform_resolver` — a callable `->(name) { callable_or_raise }`.
15
- # `transform_context` — `Application::Context` handed to the transform reducer.
16
- def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
17
- @reader = reader
18
- @spec = spec || {}
19
- @lister = lister
20
- @transform_resolver = transform_resolver
21
- @transform_context = transform_context
14
+ # `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
15
+ # `transform_context` — capability object handed to transform reducers as `caps:`.
16
+ def initialize(reader:, spec:, lister:, rpc:, transform_context:)
17
+ @reader = reader
18
+ @spec = spec || {}
19
+ @lister = lister
20
+ @rpc = rpc
21
+ @transform_context = transform_context
22
22
  @limit = (@spec["limit"] || MAX_LIMIT).to_i
23
23
  raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
24
24
  end
@@ -49,9 +49,11 @@ module Textus
49
49
 
50
50
  def apply_reducer(rows)
51
51
  name = @spec["transform"] or return rows
52
- callable = @transform_resolver.call(name)
53
52
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
54
- callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
53
+ @rpc.invoke(:transform_rows, name,
54
+ caps: @transform_context,
55
+ rows: rows,
56
+ config: @spec["transform_config"] || {})
55
57
  end
56
58
  rescue Timeout::Error
57
59
  raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
@@ -0,0 +1,106 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Textus
5
+ module Application
6
+ module Read
7
+ # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
8
+ # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
9
+ # rows produce nil and are skipped).
10
+ module Audit
11
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
12
+ Impl.new(caps: caps).call(*, **)
13
+ end
14
+
15
+ def self.parse_since(str, now: Time.now.utc)
16
+ Impl.parse_since(str, now: now)
17
+ end
18
+
19
+ class Impl
20
+ def initialize(caps:)
21
+ @manifest = caps.manifest
22
+ @root = caps.root
23
+ @log_path = File.join(caps.root, "audit.log")
24
+ @audit_log = caps.audit_log
25
+ end
26
+
27
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
28
+ def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil)
29
+ check_cursor_expiry!(seq_since)
30
+
31
+ files = all_log_files
32
+ return [] if files.empty?
33
+
34
+ rows = []
35
+ files.each do |file|
36
+ File.foreach(file) do |line|
37
+ parsed = parse_row(line.chomp)
38
+ next unless parsed
39
+ next if key && parsed["key"] != key
40
+ next if role && parsed["role"] != role
41
+ next if verb && parsed["verb"] != verb
42
+ next if zone && !key_in_zone?(parsed["key"], zone)
43
+ next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
44
+ next if seq_since && (parsed["seq"].nil? || parsed["seq"] <= seq_since)
45
+ next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
46
+
47
+ rows << parsed
48
+ break if limit && rows.length >= limit
49
+ end
50
+ break if limit && rows.length >= limit
51
+ end
52
+ rows
53
+ end
54
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
55
+
56
+ # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
57
+ # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
58
+ def self.parse_since(str, now: Time.now.utc)
59
+ return nil if str.nil? || str.empty?
60
+ return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
61
+
62
+ m = str.match(/\A(\d+)([smhd])\z/) or return nil
63
+ mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
64
+ now - (m[1].to_i * mult)
65
+ end
66
+
67
+ private
68
+
69
+ def check_cursor_expiry!(seq_since)
70
+ return unless seq_since
71
+
72
+ log = @audit_log || Textus::Infra::AuditLog.new(@root)
73
+ min = log.min_available_seq
74
+ raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
75
+ end
76
+
77
+ def all_log_files
78
+ rotated = Dir.glob(File.join(@root, "audit.log.*"))
79
+ .reject { |p| p.end_with?(".meta.json") }
80
+ .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
81
+ active = File.exist?(@log_path) ? [@log_path] : []
82
+ rotated + active
83
+ end
84
+
85
+ def parse_row(line)
86
+ return nil if line.empty?
87
+ return nil unless line.start_with?("{")
88
+
89
+ JSON.parse(line)
90
+ rescue JSON::ParserError
91
+ nil
92
+ end
93
+
94
+ def key_in_zone?(key, zone)
95
+ mentry = @manifest.resolver.resolve(key).entry
96
+ mentry && mentry.zone == zone
97
+ rescue Textus::Error
98
+ false
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ Textus::Application::UseCase.register(:audit, Textus::Application::Read::Audit, caps: :read)
@@ -0,0 +1,91 @@
1
+ require "open3"
2
+
3
+ module Textus
4
+ module Application
5
+ module Read
6
+ # For one key, joins every audit-log row with the git commit (sha,
7
+ # author, date, subject) that introduced the file state at that audit
8
+ # row. Falls back to `git => nil` when not in a git repo or when the
9
+ # file is untracked.
10
+ module Blame
11
+ def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
12
+ Impl.new(caps: caps).call(*, **)
13
+ end
14
+
15
+ class Impl
16
+ def initialize(caps:)
17
+ @caps = caps
18
+ @manifest = caps.manifest
19
+ @root = caps.root
20
+ end
21
+
22
+ def call(key:, limit: nil)
23
+ audit_rows = Textus::Application::Read::Audit::Impl.new(caps: @caps).call(key: key, limit: limit)
24
+ path = resolve_path(key)
25
+ return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
26
+
27
+ audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
28
+ end
29
+
30
+ private
31
+
32
+ def resolve_path(key)
33
+ res = @manifest.resolver.resolve(key)
34
+ mentry = res.entry
35
+ path = res.path
36
+ # Nested entries resolve to a file under the entry path; leaf entries
37
+ # already have a fully-resolved path. Either way `path` is what git
38
+ # needs to know about.
39
+ path || Textus::Key::Path.resolve(@manifest.data, mentry)
40
+ rescue Textus::Error
41
+ nil
42
+ end
43
+
44
+ def git_tracked?(path)
45
+ return false if path.nil?
46
+ return false unless File.exist?(path)
47
+ return false unless git_repo?
48
+
49
+ _out, _err, status = Open3.capture3(
50
+ "git", "ls-files", "--error-unmatch", path,
51
+ chdir: @root
52
+ )
53
+ status.success?
54
+ rescue Errno::ENOENT
55
+ false
56
+ end
57
+
58
+ def git_repo?
59
+ # Walk up from store root to find a .git directory.
60
+ dir = @root
61
+ loop do
62
+ return true if File.directory?(File.join(dir, ".git"))
63
+
64
+ parent = File.dirname(dir)
65
+ return false if parent == dir
66
+
67
+ dir = parent
68
+ end
69
+ end
70
+
71
+ def git_commit_at(path, timestamp:)
72
+ args = ["git", "log", "-1"]
73
+ args << "--before=#{timestamp}" if timestamp
74
+ args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
75
+ out, _err, status = Open3.capture3(*args, chdir: @root)
76
+ return nil unless status.success?
77
+
78
+ sha, author, date, subject = out.strip.split("\t", 4)
79
+ return nil if sha.nil? || sha.empty?
80
+
81
+ { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
82
+ rescue Errno::ENOENT
83
+ nil
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ Textus::Application::UseCase.register(:blame, Textus::Application::Read::Blame, caps: :read)