textus 0.53.0 → 0.54.1

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/CHANGELOG.md +52 -0
  3. data/README.md +53 -73
  4. data/SPEC.md +2 -5
  5. data/docs/architecture/README.md +149 -159
  6. data/lib/textus/action/accept.rb +0 -9
  7. data/lib/textus/action/audit.rb +0 -2
  8. data/lib/textus/action/blame.rb +0 -2
  9. data/lib/textus/action/boot.rb +2 -11
  10. data/lib/textus/action/data_mv.rb +1 -3
  11. data/lib/textus/action/deps.rb +1 -15
  12. data/lib/textus/action/doctor.rb +0 -2
  13. data/lib/textus/action/drain.rb +4 -6
  14. data/lib/textus/action/enqueue.rb +3 -5
  15. data/lib/textus/action/get.rb +1 -3
  16. data/lib/textus/action/ingest.rb +135 -0
  17. data/lib/textus/action/jobs.rb +1 -3
  18. data/lib/textus/action/key_delete.rb +0 -10
  19. data/lib/textus/action/key_delete_prefix.rb +1 -3
  20. data/lib/textus/action/key_mv.rb +1 -15
  21. data/lib/textus/action/key_mv_prefix.rb +1 -3
  22. data/lib/textus/action/list.rb +0 -2
  23. data/lib/textus/action/propose.rb +0 -2
  24. data/lib/textus/action/published.rb +0 -2
  25. data/lib/textus/action/pulse.rb +7 -38
  26. data/lib/textus/action/put.rb +1 -12
  27. data/lib/textus/action/rdeps.rb +2 -12
  28. data/lib/textus/action/reject.rb +0 -12
  29. data/lib/textus/action/rule_explain.rb +0 -4
  30. data/lib/textus/action/rule_lint.rb +1 -3
  31. data/lib/textus/action/rule_list.rb +0 -4
  32. data/lib/textus/action/schema_envelope.rb +0 -2
  33. data/lib/textus/action/uid.rb +0 -2
  34. data/lib/textus/action/where.rb +0 -2
  35. data/lib/textus/action/write_verb.rb +4 -18
  36. data/lib/textus/boot.rb +40 -109
  37. data/lib/textus/command.rb +6 -1
  38. data/lib/textus/container.rb +21 -5
  39. data/lib/textus/core/freshness/evaluator.rb +18 -34
  40. data/lib/textus/core/freshness/verdict.rb +1 -1
  41. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  42. data/lib/textus/doctor/check/protocol_version.rb +6 -6
  43. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  44. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
  45. data/lib/textus/doctor/check.rb +0 -1
  46. data/lib/textus/doctor.rb +3 -42
  47. data/lib/textus/envelope/reader.rb +46 -0
  48. data/lib/textus/envelope/writer.rb +209 -0
  49. data/lib/textus/envelope.rb +19 -6
  50. data/lib/textus/errors.rb +9 -5
  51. data/lib/textus/{entry → format}/base.rb +1 -6
  52. data/lib/textus/{entry → format}/json.rb +1 -4
  53. data/lib/textus/{entry → format}/markdown.rb +2 -6
  54. data/lib/textus/{entry → format}/text.rb +2 -6
  55. data/lib/textus/{entry → format}/yaml.rb +1 -4
  56. data/lib/textus/{entry.rb → format.rb} +9 -10
  57. data/lib/textus/gate/auth.rb +58 -12
  58. data/lib/textus/gate.rb +2 -0
  59. data/lib/textus/init.rb +16 -79
  60. data/lib/textus/jobs/base.rb +23 -0
  61. data/lib/textus/jobs/materialize.rb +20 -0
  62. data/lib/textus/{background → jobs}/plan.rb +1 -1
  63. data/lib/textus/jobs/planner.rb +101 -0
  64. data/lib/textus/jobs/retention.rb +48 -0
  65. data/lib/textus/jobs/sweep.rb +27 -0
  66. data/lib/textus/{background → jobs}/worker.rb +2 -2
  67. data/lib/textus/jobs.rb +15 -0
  68. data/lib/textus/key/path.rb +1 -1
  69. data/lib/textus/manifest/entry/base.rb +4 -19
  70. data/lib/textus/manifest/entry/parser.rb +27 -10
  71. data/lib/textus/manifest/entry/produced.rb +13 -34
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  73. data/lib/textus/manifest/entry/publish.rb +0 -12
  74. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  75. data/lib/textus/manifest/entry/validators.rb +0 -1
  76. data/lib/textus/manifest/policy/source.rb +14 -62
  77. data/lib/textus/manifest/policy.rb +2 -5
  78. data/lib/textus/manifest/resolver.rb +2 -2
  79. data/lib/textus/manifest/schema/contract.rb +61 -0
  80. data/lib/textus/manifest/schema/keys.rb +6 -20
  81. data/lib/textus/manifest/schema/semantics.rb +232 -0
  82. data/lib/textus/manifest/schema/validator.rb +24 -302
  83. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  84. data/lib/textus/manifest/schema.rb +1 -4
  85. data/lib/textus/manifest.rb +1 -3
  86. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  87. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  88. data/lib/textus/produce/engine.rb +50 -0
  89. data/lib/textus/produce/render.rb +20 -0
  90. data/lib/textus/session.rb +15 -8
  91. data/lib/textus/store.rb +5 -14
  92. data/lib/textus/surfaces/mcp/catalog.rb +23 -11
  93. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  94. data/lib/textus/surfaces/mcp/server.rb +87 -110
  95. data/lib/textus/surfaces/role_scope.rb +0 -4
  96. data/lib/textus/surfaces/watcher.rb +3 -3
  97. data/lib/textus/types.rb +15 -0
  98. data/lib/textus/version.rb +2 -2
  99. data/lib/textus/workflow/collector.rb +27 -0
  100. data/lib/textus/workflow/context.rb +5 -0
  101. data/lib/textus/workflow/dsl.rb +44 -0
  102. data/lib/textus/workflow/loader.rb +17 -0
  103. data/lib/textus/workflow/pattern.rb +18 -0
  104. data/lib/textus/workflow/registry.rb +21 -0
  105. data/lib/textus/workflow/runner.rb +99 -0
  106. data/lib/textus/workflow.rb +28 -0
  107. data/lib/textus.rb +12 -1
  108. metadata +67 -66
  109. data/lib/textus/action/pulse/scanner.rb +0 -118
  110. data/lib/textus/background/job/base.rb +0 -27
  111. data/lib/textus/background/job/materialize.rb +0 -31
  112. data/lib/textus/background/job/refresh.rb +0 -22
  113. data/lib/textus/background/job/sweep.rb +0 -31
  114. data/lib/textus/background/job.rb +0 -19
  115. data/lib/textus/background/planner/plan.rb +0 -113
  116. data/lib/textus/background/retention/apply.rb +0 -50
  117. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  118. data/lib/textus/doctor/check/hooks.rb +0 -25
  119. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  120. data/lib/textus/envelope/io/reader.rb +0 -48
  121. data/lib/textus/envelope/io/writer.rb +0 -211
  122. data/lib/textus/events.rb +0 -21
  123. data/lib/textus/init/templates/machine_intake.rb +0 -49
  124. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  125. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  126. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  127. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  128. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  129. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  130. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  131. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  132. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  133. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  134. data/lib/textus/pipeline/engine.rb +0 -97
  135. data/lib/textus/pipeline/render.rb +0 -25
  136. data/lib/textus/ports/audit_subscriber.rb +0 -42
  137. data/lib/textus/projection.rb +0 -103
  138. data/lib/textus/step/base.rb +0 -35
  139. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  140. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  141. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  142. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  143. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  144. data/lib/textus/step/builtin.rb +0 -22
  145. data/lib/textus/step/catalog.rb +0 -37
  146. data/lib/textus/step/context.rb +0 -74
  147. data/lib/textus/step/discovery.rb +0 -24
  148. data/lib/textus/step/error_log.rb +0 -32
  149. data/lib/textus/step/event_bus.rb +0 -113
  150. data/lib/textus/step/fetch.rb +0 -13
  151. data/lib/textus/step/fire_report.rb +0 -23
  152. data/lib/textus/step/loader.rb +0 -108
  153. data/lib/textus/step/observe.rb +0 -31
  154. data/lib/textus/step/registry_store.rb +0 -66
  155. data/lib/textus/step/signature.rb +0 -31
  156. data/lib/textus/step/transform.rb +0 -12
  157. data/lib/textus/step/validate.rb +0 -11
  158. data/lib/textus/step.rb +0 -10
  159. data/lib/textus/surfaces/mcp/session.rb +0 -9
  160. data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
@@ -5,7 +5,7 @@ module Textus
5
5
  module FormatMatrix
6
6
  def self.call(entry, policy:) # rubocop:disable Lint/UnusedMethodArgument
7
7
  begin
8
- Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
8
+ Textus::Format.for(entry.format).validate_path_extension(entry.path, entry.nested?)
9
9
  rescue UsageError => e
10
10
  raise UsageError.new("entry '#{entry.key}': #{e.message}")
11
11
  end
@@ -3,7 +3,6 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  REGISTERED = [
6
- Events,
7
6
  Publish,
8
7
  Ignore,
9
8
  FormatMatrix,
@@ -1,77 +1,29 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Policy
4
- # An entry's data-acquisition declaration (ADR 0094). `source:` says HOW the
5
- # entry's data is acquired; rendering is a publish concern, so there are no
6
- # template/render fields here. `from` is the acquire + staleness axis:
7
- # from: derive -> derived (internal projection; observable -> rdeps staleness)
8
- # from: fetch -> intake (external fetch; unobservable -> ttl staleness)
9
- # from: external -> external (out-of-band runner; staleness only, textus never runs it)
10
- # Materialization is async-only (job-queue model): a write enqueues a
11
- # `materialize` job, converged by a worker. There is no per-entry write
12
- # trigger knob.
4
+ # An entry's external-generator declaration. `source:` now means ONLY
5
+ # `from: external` out-of-band runner; textus never invokes it, only
6
+ # detects drift (doctor generator_drift check).
7
+ # `from: fetch` and `from: derive` are removed; those concerns now live
8
+ # in workflow files under .textus/workflows/.
13
9
  class Source
14
- FROMS = %w[fetch derive external].freeze
15
-
16
- attr_reader :from, :handler, :config, :command, :sources
10
+ attr_reader :command, :sources
17
11
 
18
12
  def initialize(raw)
19
- @from = raw["from"].to_s
20
- unless FROMS.include?(@from)
21
- raise Textus::BadManifest.new("source.from must be one of #{FROMS.join("|")}, got #{raw["from"].inspect}")
13
+ from = raw["from"].to_s
14
+ unless from == "external"
15
+ raise Textus::BadManifest.new(
16
+ "from: #{from} is removed — use a workflow file under .textus/workflows/ instead",
17
+ )
22
18
  end
23
19
 
24
- @ttl = raw["ttl"]
25
- @projection = {}
26
-
27
- case @from
28
- when "fetch" then init_fetch(raw)
29
- when "derive" then init_derive(raw)
30
- when "external" then init_external(raw)
31
- end
32
- end
33
-
34
- def kind
35
- { "fetch" => :intake, "derive" => :derived, "external" => :external }.fetch(@from)
36
- end
37
-
38
- def fetch? = @from == "fetch"
39
- def derive? = @from == "derive"
40
- def external? = @from == "external"
41
- def projection? = derive?
42
- def ttl_seconds = @ttl.nil? ? nil : Textus::Core::Duration.seconds(@ttl)
43
-
44
- # Flattened projection accessors (ADR 0094) — read directly off the source
45
- # block; nil when absent or not a projection source.
46
- def select = @projection["select"]
47
- def pluck = @projection["pluck"]
48
- def sort_by = @projection["sort_by"]
49
- def transform = @projection["transform"]
50
-
51
- # The projection spec hash fed to Textus::Projection (string keys, only the
52
- # present fields). {} when not a projection.
53
- def projection_spec = @projection.dup
54
-
55
- private
56
-
57
- def init_derive(raw)
58
- %w[select pluck sort_by transform].each { |f| @projection[f] = raw[f] if raw.key?(f) }
59
- return unless @projection["select"].nil? && @projection["transform"].nil?
60
-
61
- raise Textus::BadManifest.new("source (from: derive) requires `select:` and/or `transform:`")
62
- end
63
-
64
- def init_fetch(raw)
65
- @handler = raw["handler"] or
66
- raise Textus::BadManifest.new("source (from: fetch) requires a `handler:` field")
67
- @config = raw["config"] || {}
68
- end
69
-
70
- def init_external(raw)
71
20
  @command = raw["command"] or
72
21
  raise Textus::BadManifest.new("source (from: external) requires a `command:` field")
73
22
  @sources = raw["sources"] || []
74
23
  end
24
+
25
+ def external? = true
26
+ def kind = :external
75
27
  end
76
28
  end
77
29
  end
@@ -62,11 +62,8 @@ module Textus
62
62
  # and ask it directly. Returns false if entries are not yet built
63
63
  # (validator phase during Data#initialize) — validators must not rely on
64
64
  # cross-entry state during construction.
65
- def derived_entry?(key)
66
- return false if @data.entries.nil?
67
-
68
- entry = @data.entries.find { |e| e.key == key } or return false
69
- entry.derived?
65
+ def derived_entry?(_key)
66
+ false
70
67
  end
71
68
 
72
69
  # The single lane declaring kind: machine, or nil.
@@ -53,7 +53,7 @@ module Textus
53
53
  else
54
54
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
55
55
 
56
- primary_ext = Textus::Entry.for_format(entry.format).extensions.first
56
+ primary_ext = Textus::Format.for(entry.format).extensions.first
57
57
  base = Textus::Key::Path.normalize_relative_path(entry.path)
58
58
  path = File.join(@data.root, base, *remaining) + primary_ext
59
59
  Resolution.new(entry: entry, path: path, remaining: remaining)
@@ -111,7 +111,7 @@ module Textus
111
111
  end
112
112
 
113
113
  def nested_glob(format)
114
- Textus::Entry.for_format(format).nested_glob
114
+ Textus::Format.for(format).nested_glob
115
115
  end
116
116
  end
117
117
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema"
4
+
5
+ module Textus
6
+ class Manifest
7
+ module Schema
8
+ # rubocop:disable Metrics/BlockLength
9
+ Contract = Dry::Schema.JSON do
10
+ required(:lanes).value(:array).each do
11
+ hash do
12
+ required(:name).value(:string)
13
+ required(:kind).value(included_in?: Vocabulary::LANE_KINDS)
14
+ optional(:owner).value(:string)
15
+ optional(:desc).value(:string)
16
+ end
17
+ end
18
+
19
+ optional(:roles).value(:array).each do
20
+ hash do
21
+ required(:name).value(:string)
22
+ optional(:can).value(:array).each(:string)
23
+ end
24
+ end
25
+
26
+ optional(:entries).value(:array).each do
27
+ hash do
28
+ required(:key).value(:string)
29
+ required(:lane).value(:string)
30
+ optional(:path).value(:string)
31
+ optional(:owner).value(:string)
32
+ optional(:format).value(:string)
33
+ optional(:schema).maybe(:string)
34
+ optional(:kind).value(:string)
35
+ optional(:nested).value(:bool)
36
+ optional(:tracked).value(:bool)
37
+ optional(:ignore)
38
+ optional(:source)
39
+ optional(:publish).value(:array)
40
+ end
41
+ end
42
+
43
+ optional(:rules).value(:array).each do
44
+ hash do
45
+ optional(:match).value(:string)
46
+ optional(:guard)
47
+ optional(:retention)
48
+ optional(:react)
49
+ end
50
+ end
51
+
52
+ optional(:audit).hash do
53
+ optional(:max_size).value(:integer)
54
+ optional(:keep).value(:integer)
55
+ end
56
+ optional(:version).value(:string)
57
+ end
58
+ # rubocop:enable Metrics/BlockLength
59
+ end
60
+ end
61
+ end
@@ -9,23 +9,16 @@ module Textus
9
9
  LANE_KEYS = %w[name kind owner desc].freeze
10
10
  ENTRY_KEYS = %w[
11
11
  key path lane kind schema owner nested format
12
- source publish
13
- events ignore tracked
12
+ source publish tracked ignore
14
13
  ].freeze
15
14
  # ADR 0052: the typed publish block — `publish: { to: [...] }` (file
16
15
  # fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
17
16
  PUBLISH_KEYS = %w[to tree].freeze
18
- # ADR 0093/0094: entry-level acquisition block. `from: project` sources
19
- # expose flat projection fields (select/pluck/sort_by/transform) directly
20
- # on the source block (ADR 0094). Render fields (template/inject_boot/
21
- # provenance) that were formerly on the source are retired — they live on
22
- # publish targets. The legacy `project:` free hash and `template`/
23
- # `inject_boot`/`provenance` fields are kept here so the schema walk can
24
- # still emit the migration hint rather than a bare "unknown key".
25
- SOURCE_KEYS = %w[
26
- from handler config template project command sources ttl inject_boot provenance
27
- select pluck sort_by transform
28
- ].freeze
17
+ # entry-level external-generator block — only `from: external` is valid.
18
+ # `from: fetch` and `from: derive` and their specific fields (handler,
19
+ # config, ttl, select, pluck, sort_by, transform) were removed in the
20
+ # workflow redesign.
21
+ SOURCE_KEYS = %w[from command sources].freeze
29
22
  # ADR 0093: rule-level GC slot. drop/archive only (refresh gone).
30
23
  RETENTION_KEYS = %w[ttl action].freeze
31
24
 
@@ -60,13 +53,6 @@ module Textus
60
53
  # Key order here fixes the order of RULE_KEYS (after match), the slots,
61
54
  # the RuleSet members, and the doctor SLOTS.
62
55
  FIELD_REGISTRY = {
63
- handler_permit: {
64
- yaml_key: "handler_permit",
65
- policy_class: Textus::Manifest::Policy::HandlerPermit,
66
- validation: :immediate, sub_keys: nil, arg_key: :handlers,
67
- in_pick: true, in_ambiguity: true,
68
- in_rule_list: true, in_rule_explain: %i[detail]
69
- },
70
56
  guard: {
71
57
  yaml_key: "guard",
72
58
  policy_class: nil,
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Manifest
5
+ module Schema
6
+ # Cross-field rules and ADR migration hints. Called by Validator.validate!
7
+ # AFTER the structural dry-schema Contract passes. Operates on the raw hash.
8
+ module Semantics
9
+ module_function
10
+
11
+ def check!(raw)
12
+ check_roles!(raw["roles"])
13
+ check_lanes!(raw["lanes"])
14
+ check_entries!(raw["entries"])
15
+ check_owners!(raw["lanes"], raw["entries"])
16
+ check_rules!(raw["rules"])
17
+ check_single_queue!(raw)
18
+ check_single_machine!(raw)
19
+ check_lane_kind_consistency!(raw)
20
+ walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
21
+ end
22
+
23
+ def check_roles!(roles)
24
+ return if roles.nil?
25
+
26
+ roles.each_with_index do |r, i|
27
+ path = "$.roles[#{i}]"
28
+ name = r["name"]
29
+ unless Textus::Role::NAMES.include?(name)
30
+ raise BadManifest.new(
31
+ "unknown role name '#{name}' at '#{path}' (allowed: #{Textus::Role::NAMES.join(", ")})",
32
+ )
33
+ end
34
+ Array(r["can"]).each do |verb|
35
+ next if CAPABILITIES.include?(verb)
36
+
37
+ hint = %w[ingest fetch].include?(verb) ? " — the quarantine capability folded into 'converge' (ADR 0090)" : ""
38
+ raise BadManifest.new(
39
+ "unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
40
+ "(known: #{CAPABILITIES.join(", ")})#{hint}",
41
+ )
42
+ end
43
+ end
44
+
45
+ author_holders = roles.count { |r| Array(r["can"]).include?("author") }
46
+ return if author_holders <= 1
47
+
48
+ raise BadManifest.new(
49
+ "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
50
+ )
51
+ end
52
+
53
+ def check_lanes!(lanes)
54
+ Array(lanes).each_with_index do |z, i|
55
+ walk(z, LANE_KEYS, "$.lanes[#{i}]")
56
+ next unless %w[quarantine derived].include?(z["kind"])
57
+
58
+ raise BadManifest.new(
59
+ "lane kind '#{z["kind"]}' at '$.lanes[#{i}]' was folded into 'machine' (ADR 0091) — " \
60
+ "use `kind: machine`",
61
+ )
62
+ end
63
+ end
64
+
65
+ def check_entries!(entries)
66
+ Array(entries).each_with_index do |e, i|
67
+ path = "$.entries[#{i}]"
68
+ check_retired_publish_keys!(e, path)
69
+ check_retired_render_keys!(e, path)
70
+ walk(e, ENTRY_KEYS, path)
71
+ check_publish_block!(e, path)
72
+ walk(e["source"], SOURCE_KEYS, "#{path}.source") if e.is_a?(Hash) && e["source"].is_a?(Hash)
73
+ end
74
+ end
75
+
76
+ def check_retired_publish_keys!(entry, path)
77
+ return unless entry.is_a?(Hash)
78
+
79
+ if entry.key?("publish_each")
80
+ raise BadManifest.new(
81
+ "publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
82
+ "mirror the subtree with `publish: { tree: \"...\" }`.",
83
+ )
84
+ end
85
+ if entry.key?("publish_to")
86
+ raise BadManifest.new(
87
+ "publish_to was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
88
+ "use `publish: { to: [...] }`.",
89
+ )
90
+ end
91
+ if entry.key?("publish_tree")
92
+ raise BadManifest.new(
93
+ "publish_tree was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
94
+ "use `publish: { tree: \"...\" }`.",
95
+ )
96
+ end
97
+ return unless entry.key?("index_filename")
98
+
99
+ raise BadManifest.new(
100
+ "index_filename was removed in 0.43.0 (ADR 0053) at '#{path}'.",
101
+ )
102
+ end
103
+
104
+ def check_retired_render_keys!(entry, path)
105
+ return unless entry.is_a?(Hash)
106
+
107
+ if entry.key?("template")
108
+ raise BadManifest.new(
109
+ "entry-level `template:` was removed at '#{path}' (ADR 0094): rendering is a " \
110
+ "publish concern — `publish: [{ to:, template: }]`.",
111
+ )
112
+ end
113
+ if entry.key?("inject_boot")
114
+ raise BadManifest.new(
115
+ "entry-level `inject_boot:` was removed at '#{path}' (ADR 0094).",
116
+ )
117
+ end
118
+ return unless entry.key?("provenance")
119
+
120
+ raise BadManifest.new("entry-level `provenance:` was removed at '#{path}' (ADR 0094).")
121
+ end
122
+
123
+ def check_publish_block!(entry, path)
124
+ return unless entry.is_a?(Hash) && entry.key?("publish")
125
+
126
+ block = entry["publish"]
127
+ if block.is_a?(Hash)
128
+ raise BadManifest.new(
129
+ "publish: at '#{path}.publish' must be a list of targets (ADR 0094); the map form was retired.",
130
+ )
131
+ end
132
+ raise BadManifest.new("publish: must be a list of targets at '#{path}.publish'") unless block.is_a?(Array)
133
+
134
+ block.each_with_index do |t, i|
135
+ raise BadManifest.new("publish target ##{i} must be a mapping at '#{path}.publish'") unless t.is_a?(Hash)
136
+
137
+ walk(t, %w[to tree template inject_boot], "#{path}.publish[#{i}]")
138
+ end
139
+ end
140
+
141
+ def check_owners!(lanes, entries)
142
+ Array(lanes).each_with_index { |z, i| check_owner!(z["owner"], "$.lanes[#{i}]") }
143
+ Array(entries).each_with_index { |e, i| check_owner!(e["owner"], "$.entries[#{i}]") }
144
+ end
145
+
146
+ def check_owner!(owner, path)
147
+ return if owner.nil?
148
+ return if valid_owner?(owner)
149
+
150
+ raise BadManifest.new(
151
+ "invalid owner '#{owner}' at '#{path}' " \
152
+ "(expected <archetype> or <archetype>:<subject>, archetype one of: #{Textus::Role::NAMES.join(", ")})",
153
+ )
154
+ end
155
+
156
+ def valid_owner?(token)
157
+ return false unless token.is_a?(String) && !token.empty?
158
+
159
+ archetype, subject = token.split(":", 2)
160
+ return false unless Textus::Role::NAMES.include?(archetype)
161
+ return true if subject.nil?
162
+
163
+ OWNER_SUBJECT_PATTERN.match?(subject)
164
+ end
165
+
166
+ def check_rules!(rules)
167
+ Array(rules).each_with_index do |r, i|
168
+ path = "$.rules[#{i}]"
169
+ # Check retired keys BEFORE the generic walk so specific hints fire first.
170
+ { "lifecycle" => "age GC moved to `retention:` rule", "materialize" => "removed (ADR 0093)" }
171
+ .each do |old, hint|
172
+ next unless r.is_a?(Hash) && r.key?(old)
173
+
174
+ raise BadManifest.new("`#{old}:` was removed at '#{path}' (ADR 0093) — #{hint}.")
175
+ end
176
+ if r.is_a?(Hash) && r.key?("upkeep")
177
+ raise BadManifest.new(
178
+ "rule key `upkeep:` was removed (ADR 0093): move age-GC to `retention:` " \
179
+ "and production to the entry's `source:`",
180
+ )
181
+ end
182
+ walk(r, RULE_KEYS, path)
183
+ FIELD_REGISTRY.each_value do |meta|
184
+ next unless meta[:sub_keys]
185
+
186
+ value = r.is_a?(Hash) ? r[meta[:yaml_key]] : nil
187
+ walk(value, meta[:sub_keys], "#{path}.#{meta[:yaml_key]}") if value.is_a?(Hash)
188
+ end
189
+ end
190
+ end
191
+
192
+ def check_single_queue!(raw)
193
+ queues = Array(raw["lanes"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
194
+ return if queues.size <= 1
195
+
196
+ raise BadManifest.new("at most one lane may declare kind: queue (found: #{queues.join(", ")})")
197
+ end
198
+
199
+ def check_single_machine!(raw)
200
+ machines = Array(raw["lanes"]).select { |z| z["kind"] == "machine" }.map { |z| z["name"] }
201
+ return if machines.size <= 1
202
+
203
+ raise BadManifest.new("at most one lane may declare kind: machine (found: #{machines.join(", ")})")
204
+ end
205
+
206
+ def check_lane_kind_consistency!(raw)
207
+ held = Capabilities.resolve(raw["roles"]).values.flatten.uniq
208
+
209
+ Array(raw["lanes"]).each_with_index do |z, i|
210
+ verb = KIND_REQUIRES_VERB[z["kind"]]
211
+ next if verb.nil? || held.include?(verb)
212
+
213
+ raise BadManifest.new(
214
+ "lane '#{z["name"]}' (#{z["kind"]}) at '$.lanes[#{i}]' " \
215
+ "needs a role with capability '#{verb}'; none declared",
216
+ )
217
+ end
218
+ end
219
+
220
+ def walk(hash, allowed, path)
221
+ return unless hash.is_a?(Hash)
222
+
223
+ hash.each_key do |k|
224
+ next if allowed.include?(k)
225
+
226
+ raise BadManifest.new("unknown key '#{k}' at '#{path}'")
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end