textus 0.50.0 → 0.52.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +176 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +15 -17
  8. data/lib/textus/call.rb +1 -1
  9. data/lib/textus/cli/runner.rb +15 -10
  10. data/lib/textus/cli/verb/get.rb +1 -3
  11. data/lib/textus/cli/verb/hook_run.rb +1 -1
  12. data/lib/textus/cli/verb/put.rb +4 -20
  13. data/lib/textus/cli/verb/serve.rb +19 -0
  14. data/lib/textus/cli.rb +1 -3
  15. data/lib/textus/dispatcher.rb +3 -3
  16. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  17. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  18. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  19. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  20. data/lib/textus/doctor/check/sentinels.rb +2 -2
  21. data/lib/textus/doctor/check/templates.rb +13 -11
  22. data/lib/textus/doctor.rb +0 -2
  23. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  24. data/lib/textus/domain/freshness/verdict.rb +28 -6
  25. data/lib/textus/domain/freshness.rb +4 -33
  26. data/lib/textus/domain/jobs/job.rb +58 -0
  27. data/lib/textus/domain/jobs/registry.rb +37 -0
  28. data/lib/textus/domain/policy/base_guards.rb +1 -1
  29. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  30. data/lib/textus/domain/policy/publish_target.rb +34 -0
  31. data/lib/textus/domain/policy/retention.rb +29 -0
  32. data/lib/textus/domain/policy/source.rb +73 -0
  33. data/lib/textus/domain/retention/sweep.rb +57 -0
  34. data/lib/textus/domain/retention.rb +11 -0
  35. data/lib/textus/errors.rb +4 -4
  36. data/lib/textus/hooks/builtin.rb +5 -5
  37. data/lib/textus/hooks/catalog.rb +7 -7
  38. data/lib/textus/hooks/context.rb +5 -10
  39. data/lib/textus/init/templates/machine_intake.rb +4 -4
  40. data/lib/textus/init.rb +47 -47
  41. data/lib/textus/jobs/handlers.rb +62 -0
  42. data/lib/textus/jobs/scheduler.rb +36 -0
  43. data/lib/textus/jobs/seeder.rb +57 -0
  44. data/lib/textus/key/matching.rb +24 -0
  45. data/lib/textus/layout.rb +8 -0
  46. data/lib/textus/maintenance/drain.rb +42 -0
  47. data/lib/textus/maintenance/retention/apply.rb +52 -0
  48. data/lib/textus/maintenance/serve.rb +30 -0
  49. data/lib/textus/maintenance/worker.rb +74 -0
  50. data/lib/textus/manifest/capabilities.rb +1 -1
  51. data/lib/textus/manifest/data.rb +18 -3
  52. data/lib/textus/manifest/entry/base.rb +28 -9
  53. data/lib/textus/manifest/entry/nested.rb +3 -4
  54. data/lib/textus/manifest/entry/parser.rb +25 -21
  55. data/lib/textus/manifest/entry/produced.rb +56 -0
  56. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  57. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  58. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  59. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  60. data/lib/textus/manifest/entry/validators.rb +0 -1
  61. data/lib/textus/manifest/policy.rb +16 -4
  62. data/lib/textus/manifest/resolver.rb +10 -4
  63. data/lib/textus/manifest/rules.rb +37 -36
  64. data/lib/textus/manifest/schema/keys.rb +98 -0
  65. data/lib/textus/manifest/schema/validator.rb +324 -0
  66. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  67. data/lib/textus/manifest/schema.rb +27 -247
  68. data/lib/textus/manifest.rb +5 -3
  69. data/lib/textus/mcp/server.rb +1 -1
  70. data/lib/textus/ports/audit_log.rb +6 -0
  71. data/lib/textus/ports/build_lock.rb +6 -0
  72. data/lib/textus/ports/clock.rb +4 -3
  73. data/lib/textus/ports/produce_on_write_subscriber.rb +73 -0
  74. data/lib/textus/ports/publisher.rb +11 -7
  75. data/lib/textus/ports/queue.rb +130 -0
  76. data/lib/textus/produce/acquire/handler.rb +29 -0
  77. data/lib/textus/produce/acquire/intake.rb +130 -0
  78. data/lib/textus/produce/acquire/projection.rb +127 -0
  79. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  80. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  81. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  82. data/lib/textus/produce/acquire/serializer.rb +17 -0
  83. data/lib/textus/produce/engine.rb +95 -0
  84. data/lib/textus/produce/events.rb +36 -0
  85. data/lib/textus/produce/render.rb +23 -0
  86. data/lib/textus/projection.rb +17 -6
  87. data/lib/textus/read/deps.rb +3 -3
  88. data/lib/textus/read/freshness.rb +61 -31
  89. data/lib/textus/read/get.rb +20 -102
  90. data/lib/textus/read/jobs.rb +31 -0
  91. data/lib/textus/read/rdeps.rb +3 -3
  92. data/lib/textus/read/rule_explain.rb +41 -23
  93. data/lib/textus/read/rule_list.rb +25 -8
  94. data/lib/textus/read/validate_all.rb +14 -0
  95. data/lib/textus/role.rb +2 -1
  96. data/lib/textus/schemas.rb +8 -0
  97. data/lib/textus/store.rb +1 -0
  98. data/lib/textus/version.rb +1 -1
  99. data/lib/textus/write/enqueue.rb +50 -0
  100. data/lib/textus/write/put.rb +1 -1
  101. metadata +35 -30
  102. data/lib/textus/builder/pipeline.rb +0 -88
  103. data/lib/textus/builder/renderer/json.rb +0 -45
  104. data/lib/textus/builder/renderer/markdown.rb +0 -24
  105. data/lib/textus/builder/renderer/text.rb +0 -14
  106. data/lib/textus/builder/renderer/yaml.rb +0 -45
  107. data/lib/textus/builder/renderer.rb +0 -17
  108. data/lib/textus/cli/verb/boot.rb +0 -14
  109. data/lib/textus/cli/verb/build.rb +0 -15
  110. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  111. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  112. data/lib/textus/domain/freshness/policy.rb +0 -18
  113. data/lib/textus/domain/lifecycle.rb +0 -83
  114. data/lib/textus/domain/outcome.rb +0 -10
  115. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  116. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  117. data/lib/textus/domain/staleness.rb +0 -29
  118. data/lib/textus/maintenance/tend.rb +0 -110
  119. data/lib/textus/manifest/entry/derived.rb +0 -67
  120. data/lib/textus/manifest/entry/intake.rb +0 -31
  121. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  122. data/lib/textus/mcp/tools.rb +0 -14
  123. data/lib/textus/ports/fetch/detached.rb +0 -52
  124. data/lib/textus/ports/fetch/lock.rb +0 -44
  125. data/lib/textus/write/build.rb +0 -90
  126. data/lib/textus/write/fetch_events.rb +0 -42
  127. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  128. data/lib/textus/write/fetch_worker.rb +0 -127
  129. data/lib/textus/write/intake_fetch.rb +0 -25
  130. data/lib/textus/write/materializer.rb +0 -51
@@ -1,8 +1,13 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Rules
4
- RuleSet = ::Data.define(:handler_allowlist, :guard, :lifecycle)
5
- EMPTY_SET = RuleSet.new(handler_allowlist: nil, guard: nil, lifecycle: nil)
4
+ # Every structural member here derives from Schema::FIELD_REGISTRY (WS3),
5
+ # so a new rule field is added in one place. `in_pick` selects the fields
6
+ # that participate in the most-specific `for(key)` resolution.
7
+ PICK_FIELDS = Schema::FIELD_REGISTRY.select { |_, m| m[:in_pick] }.keys.freeze
8
+
9
+ RuleSet = ::Data.define(*PICK_FIELDS)
10
+ EMPTY_SET = RuleSet.new(**PICK_FIELDS.to_h { |f| [f, nil] })
6
11
 
7
12
  def self.parse(raw)
8
13
  new(Array(raw).map { |b| Block.new(b) })
@@ -15,17 +20,13 @@ module Textus
15
20
  attr_reader :blocks
16
21
 
17
22
  def for(key)
18
- slots = { handler_allowlist: [], guard: [], lifecycle: [] }
23
+ slots = PICK_FIELDS.to_h { |f| [f, []] }
19
24
  @blocks.each do |b|
20
25
  next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
21
26
 
22
27
  slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
23
28
  end
24
- RuleSet.new(
25
- handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
26
- guard: pick(slots[:guard], :guard, key),
27
- lifecycle: pick(slots[:lifecycle], :lifecycle, key),
28
- )
29
+ RuleSet.new(**slots.to_h { |slot, blocks| [slot, pick(blocks, slot, key)] })
29
30
  end
30
31
 
31
32
  def explain(key)
@@ -43,41 +44,41 @@ module Textus
43
44
  end
44
45
 
45
46
  class Block
46
- attr_reader :match, :handler_allowlist, :guard, :lifecycle
47
+ attr_reader :match, *Schema::FIELD_REGISTRY.keys
47
48
 
48
49
  def initialize(raw)
49
50
  @match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
50
- @handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
51
- @guard = parse_guard(raw["guard"])
52
- @lifecycle = parse_lifecycle(raw["lifecycle"])
51
+ Schema::FIELD_REGISTRY.each do |field, meta|
52
+ instance_variable_set("@#{field}", parse_field(meta, raw[meta[:yaml_key]]))
53
+ end
53
54
  end
54
55
 
55
56
  private
56
57
 
57
- def parse_handler_allowlist(arr)
58
- return nil if arr.nil?
59
-
60
- Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
61
- end
62
-
63
- # A guard: block is a map of transition => [predicate specs]. Predicate
64
- # names are validated at GuardFactory build time via Predicates::Registry
65
- # (ADR 0031); here we only assert the structural shape.
66
- def parse_guard(h)
67
- return nil if h.nil?
68
- raise Textus::BadManifest.new("guard: must be a map of transition => [predicates]") unless h.is_a?(Hash)
69
-
70
- h
71
- end
72
-
73
- def parse_lifecycle(h)
74
- return nil if h.nil?
75
-
76
- Textus::Domain::Policy::Lifecycle.new(
77
- ttl: h["ttl"],
78
- on_expire: h["on_expire"],
79
- budget_ms: h["budget_ms"],
80
- )
58
+ # One dispatch over the registry, replacing the four bespoke parse_*
59
+ # methods. :deferred carries the raw Hash after a shape check (its
60
+ # contents validate later — guard predicates at GuardFactory build time,
61
+ # ADR 0031); :immediate instantiates the policy class now. :tagged passes
62
+ # the raw Hash straight to a policy class that is a tagged union and
63
+ # dispatches on its discriminator field (e.g. upkeep's on:). A mapping
64
+ # field (sub_keys) splats its nested keys as kwargs; a scalar/array
65
+ # field passes its raw value under arg_key.
66
+ def parse_field(meta, value)
67
+ return nil if value.nil?
68
+
69
+ if meta[:validation] == :deferred
70
+ raise Textus::BadManifest.new("#{meta[:yaml_key]}: must be a map of transition => [predicates]") unless value.is_a?(Hash)
71
+
72
+ return value
73
+ end
74
+
75
+ return meta[:policy_class].new(value) if meta[:validation] == :tagged
76
+
77
+ if meta[:sub_keys]
78
+ meta[:policy_class].new(**meta[:sub_keys].to_h { |k| [k.to_sym, value[k]] })
79
+ else
80
+ meta[:policy_class].new(meta[:arg_key] => value)
81
+ end
81
82
  end
82
83
  end
83
84
  end
@@ -0,0 +1,98 @@
1
+ module Textus
2
+ class Manifest
3
+ module Schema
4
+ # The manifest's key whitelists and the rule-field registry — the schema's
5
+ # data tables (ADR 0109; the vocabulary lives in Schema::Vocabulary).
6
+ module Keys
7
+ ROOT_KEYS = %w[version roles zones entries rules audit].freeze
8
+ ROLE_KEYS = %w[name can].freeze
9
+ ZONE_KEYS = %w[name kind owner desc].freeze
10
+ ENTRY_KEYS = %w[
11
+ key path zone kind schema owner nested format
12
+ source publish
13
+ events ignore tracked
14
+ ].freeze
15
+ # ADR 0052: the typed publish block — `publish: { to: [...] }` (file
16
+ # fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
17
+ 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
29
+ # ADR 0093: rule-level GC slot. drop/archive only (refresh gone).
30
+ RETENTION_KEYS = %w[ttl action].freeze
31
+
32
+ # The ONE source of truth for the rule-block field set (WS3). Adding a
33
+ # rule field means adding one entry here; everything downstream derives
34
+ # from it so the ~9 enumeration sites the audit found can't drift:
35
+ # - Schema::RULE_KEYS and the per-field sub-key walk (Schema::Validator)
36
+ # - Rules: the RuleSet members, EMPTY_SET, the `for` slots accumulator,
37
+ # Block's attr_readers, and the parse dispatch
38
+ # - Doctor::Check::RuleAmbiguity SLOTS (in_ambiguity)
39
+ # - Read::RuleList / Read::RuleExplain field membership
40
+ # (in_rule_list / in_rule_explain)
41
+ #
42
+ # Per field:
43
+ # yaml_key manifest key (handler_allowlist's intake_ prefix
44
+ # disambiguates from entry-level intake:, ADR 0059)
45
+ # policy_class the Domain::Policy backing the field (nil = raw value)
46
+ # validation :immediate (instantiate the policy at parse, surfacing
47
+ # shape errors eagerly), :deferred (shape-check + carry
48
+ # the raw Hash; guard predicates validate at GuardFactory
49
+ # build time, ADR 0031), or :tagged (pass the raw Hash to a
50
+ # tagged-union policy that dispatches on its discriminator
51
+ # field, e.g. upkeep's on:)
52
+ # sub_keys allowed nested keys for a mapping field (drives both the
53
+ # schema sub-key walk and the kwargs splat into policy_class)
54
+ # arg_key for an immediate non-mapping field, the single kwarg the
55
+ # raw value is passed under
56
+ # in_pick participates in the most-specific `for(key)` resolution
57
+ # in_ambiguity linted by doctor's same-specificity tie check
58
+ # in_rule_list shown in the whole-manifest rule_list view
59
+ # in_rule_explain depths the field shows at: :lean and/or :detail
60
+ #
61
+ # Key order here fixes the order of RULE_KEYS (after match), the slots,
62
+ # the RuleSet members, and the doctor SLOTS.
63
+ FIELD_REGISTRY = {
64
+ handler_allowlist: {
65
+ yaml_key: "intake_handler_allowlist",
66
+ policy_class: Textus::Domain::Policy::HandlerAllowlist,
67
+ validation: :immediate, sub_keys: nil, arg_key: :handlers,
68
+ in_pick: true, in_ambiguity: true,
69
+ in_rule_list: true, in_rule_explain: %i[detail]
70
+ },
71
+ guard: {
72
+ yaml_key: "guard",
73
+ policy_class: nil,
74
+ validation: :deferred, sub_keys: nil, arg_key: nil,
75
+ in_pick: true, in_ambiguity: true,
76
+ in_rule_list: true, in_rule_explain: %i[lean detail]
77
+ },
78
+ retention: {
79
+ yaml_key: "retention",
80
+ policy_class: Textus::Domain::Policy::Retention,
81
+ validation: :tagged, sub_keys: RETENTION_KEYS, arg_key: nil,
82
+ in_pick: true, in_ambiguity: true,
83
+ in_rule_list: true, in_rule_explain: %i[lean detail]
84
+ },
85
+ }.freeze
86
+
87
+ RULE_KEYS = (["match"] + FIELD_REGISTRY.values.map { |m| m[:yaml_key] }).freeze
88
+ AUDIT_KEYS = %w[max_size keep].freeze
89
+ # Syntactic shape of an `owner:` subject token (the `patrick` in
90
+ # `human:patrick`) — the subject half of the owner-validation rule below.
91
+ # Role supplies the archetype set (Role::NAMES); this pattern is the
92
+ # owner-specific part, so it lives with the rule that composes them
93
+ # (ADR 0045 D1). Acting-role *names* are gated by Role::NAMES, not a regex.
94
+ OWNER_SUBJECT_PATTERN = /\A[a-z][a-z0-9_-]*\z/
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,324 @@
1
+ module Textus
2
+ class Manifest
3
+ module Schema
4
+ # The manifest validation walk. Extracted from Schema (ADR 0107); the
5
+ # schema data now lives in Schema::Vocabulary (coordination vocabulary,
6
+ # LANES + derived) and Schema::Keys (key whitelists / FIELD_REGISTRY),
7
+ # re-exported on Schema — while the validation *logic* lives here.
8
+ # Lexically nested under Schema, so bare constant references
9
+ # (ROOT_KEYS, LANES, FIELD_REGISTRY, …) resolve to Schema's constants.
10
+ module Validator
11
+ module_function
12
+
13
+ def validate!(raw)
14
+ raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
15
+
16
+ walk(raw, ROOT_KEYS, "$")
17
+ validate_roles!(raw["roles"])
18
+ validate_zones!(raw["zones"])
19
+ validate_entries!(raw["entries"])
20
+ validate_owners!(raw["zones"], raw["entries"])
21
+ validate_rules!(raw["rules"])
22
+ walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
23
+ validate_single_queue!(raw)
24
+ validate_single_machine!(raw)
25
+ validate_zone_kind_consistency!(raw)
26
+ end
27
+
28
+ def validate_zones!(zones)
29
+ Array(zones).each_with_index do |z, i|
30
+ walk(z, ZONE_KEYS, "$.zones[#{i}]")
31
+ if z["kind"].nil?
32
+ raise BadManifest.new("zone '#{z["name"]}' at '$.zones[#{i}]' must declare a kind (one of: #{ZONE_KINDS.join(", ")})")
33
+ end
34
+ next if ZONE_KINDS.include?(z["kind"])
35
+
36
+ if %w[quarantine derived].include?(z["kind"])
37
+ raise BadManifest.new(
38
+ "zone kind '#{z["kind"]}' at '$.zones[#{i}]' was folded into 'machine' (ADR 0091) — " \
39
+ "use `kind: machine`",
40
+ )
41
+ end
42
+
43
+ raise BadManifest.new(
44
+ "unknown zone kind '#{z["kind"]}' at '$.zones[#{i}]' (known: #{ZONE_KINDS.join(", ")})",
45
+ )
46
+ end
47
+ end
48
+
49
+ def validate_entries!(entries)
50
+ Array(entries).each_with_index do |e, i|
51
+ path = "$.entries[#{i}]"
52
+ reject_retired_publish_keys!(e, path)
53
+ reject_retired_render_keys!(e, path)
54
+ walk(e, ENTRY_KEYS, path)
55
+ validate_publish_block!(e, path)
56
+ walk(e["source"], SOURCE_KEYS, "#{path}.source") if e["source"]
57
+ end
58
+ end
59
+
60
+ # Retired keys are no longer allowed, so `walk` would reject them as merely
61
+ # "unknown"; intercept first with the migration path so a pre-0.43 manifest
62
+ # gets a useful error. `publish_each` was removed (ADR 0051); `publish_to`/
63
+ # `publish_tree` were folded into the `publish:` block (ADR 0052);
64
+ # `index_filename` was removed (ADR 0053).
65
+ def reject_retired_publish_keys!(entry, path)
66
+ return unless entry.is_a?(Hash)
67
+
68
+ if entry.key?("publish_each")
69
+ raise BadManifest.new(
70
+ "publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
71
+ "mirror the subtree with `publish: { tree: \"...\" }`.",
72
+ )
73
+ end
74
+
75
+ if entry.key?("publish_to")
76
+ raise BadManifest.new(
77
+ "publish_to was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
78
+ "use `publish: { to: [...] }`.",
79
+ )
80
+ end
81
+
82
+ if entry.key?("publish_tree")
83
+ raise BadManifest.new(
84
+ "publish_tree was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
85
+ "use `publish: { tree: \"...\" }`.",
86
+ )
87
+ end
88
+
89
+ return unless entry.key?("index_filename")
90
+
91
+ raise BadManifest.new(
92
+ "index_filename was removed in 0.43.0 (ADR 0053) at '#{path}' — a nested entry now enumerates " \
93
+ "each file as a key; to mirror a directory of files to a consumer path use `publish: { tree: \"...\" }`.",
94
+ )
95
+ end
96
+
97
+ # ADR 0094: rendering is a publish concern. An entry no longer
98
+ # declares a build-time template or render flags — they move onto publish
99
+ # targets. Provenance lives in the data's `_meta`, not a flag.
100
+ def reject_retired_render_keys!(entry, path)
101
+ return unless entry.is_a?(Hash)
102
+
103
+ if entry.key?("template")
104
+ raise BadManifest.new(
105
+ "entry-level `template:` was removed at '#{path}' (ADR 0094): rendering is a " \
106
+ "publish concern — `publish: [{ to:, template: }]`.",
107
+ )
108
+ end
109
+ if entry.key?("inject_boot")
110
+ raise BadManifest.new(
111
+ "entry-level `inject_boot:` was removed at '#{path}' (ADR 0094): it is a render " \
112
+ "flag — `publish: [{ to:, inject_boot: }]`.",
113
+ )
114
+ end
115
+ return unless entry.key?("provenance")
116
+
117
+ raise BadManifest.new("entry-level `provenance:` was removed at '#{path}' (ADR 0094): provenance lives in the data's `_meta`.")
118
+ end
119
+
120
+ # ADR 0094: publish is a LIST of target objects. The old
121
+ # `{ to: [...] }` / `{ tree: … }` map forms are retired (fold hint).
122
+ def validate_publish_block!(entry, path)
123
+ return unless entry.is_a?(Hash) && entry.key?("publish")
124
+
125
+ block = entry["publish"]
126
+ if block.is_a?(Hash)
127
+ raise BadManifest.new(
128
+ "publish: at '#{path}.publish' must be a list of targets " \
129
+ "[{ to:, template:? } | { tree: }] (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 validate_rules!(rules)
142
+ Array(rules).each_with_index do |r, i|
143
+ path = "$.rules[#{i}]"
144
+ reject_retired_rule_keys!(r, path)
145
+ if r.is_a?(Hash) && r.key?("upkeep")
146
+ raise BadManifest.new(
147
+ "rule key `upkeep:` was removed (ADR 0093): move age-GC to `retention:` " \
148
+ "and production (handler/template) to the entry's `source:`",
149
+ )
150
+ end
151
+ walk(r, RULE_KEYS, path)
152
+ FIELD_REGISTRY.each_value do |meta|
153
+ next unless meta[:sub_keys]
154
+
155
+ value = r[meta[:yaml_key]]
156
+ walk(value, meta[:sub_keys], "#{path}.#{meta[:yaml_key]}") if value.is_a?(Hash)
157
+ end
158
+ end
159
+ end
160
+
161
+ # ADR 0093 split production from age-GC: age-GC moved to the `retention:`
162
+ # rule; intake cadence + production (handler/template) moved to the
163
+ # entry's `source:` block. Legacy `lifecycle:`/`materialize:` rule keys
164
+ # are rejected with a migration hint toward the new shape.
165
+ def reject_retired_rule_keys!(rule, path)
166
+ return unless rule.is_a?(Hash)
167
+
168
+ hints = {
169
+ "lifecycle" => "age GC moved to the `retention:` rule ({ ttl, action: drop|archive }); " \
170
+ "intake cadence to the entry's `source: { ttl }`",
171
+ "materialize" => "removed — materialization is automatic (a write enqueues a job; run `drain`)",
172
+ }
173
+ hints.each do |old, hint|
174
+ next unless rule.key?(old)
175
+
176
+ raise BadManifest.new("`#{old}:` was removed at '#{path}' (ADR 0093) — #{hint}.")
177
+ end
178
+ end
179
+
180
+ def validate_roles!(roles)
181
+ return if roles.nil?
182
+ raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
183
+
184
+ roles.each_with_index do |r, i|
185
+ path = "$.roles[#{i}]"
186
+ walk(r, ROLE_KEYS, path)
187
+ name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
188
+ unless Textus::Role::NAMES.include?(name)
189
+ raise BadManifest.new(
190
+ "unknown role name '#{name}' at '#{path}' " \
191
+ "(allowed: #{Textus::Role::NAMES.join(", ")})",
192
+ )
193
+ end
194
+ Array(r["can"]).each do |verb|
195
+ next if CAPABILITIES.include?(verb)
196
+
197
+ # The quarantine capability folded into the converge capability (ADR 0090); a
198
+ # manifest still naming the old quarantine capability (`ingest`, or
199
+ # legacy `fetch`) gets a pointed hint rather than a bare error.
200
+ hint = %w[ingest fetch].include?(verb) ? " — the quarantine capability folded into 'converge' (ADR 0090)" : ""
201
+ raise BadManifest.new(
202
+ "unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
203
+ "(known: #{CAPABILITIES.join(", ")})#{hint}",
204
+ )
205
+ end
206
+ end
207
+
208
+ author_holders = roles.count { |r| Array(r["can"]).include?("author") }
209
+ return if author_holders <= 1
210
+
211
+ raise BadManifest.new(
212
+ "manifest declares #{author_holders} roles with the author capability; at most one is allowed",
213
+ )
214
+ end
215
+
216
+ # Owners are validated against the SAME closed archetype set as role names
217
+ # (ADR 0045 D1) so attribution can't bypass the closed-name guarantee.
218
+ # Applies to both zone owners and entry owners; owner is optional, so a
219
+ # nil owner is not an error.
220
+ def validate_owners!(zones, entries)
221
+ Array(zones).each_with_index do |z, i|
222
+ check_owner!(z["owner"], "$.zones[#{i}]")
223
+ end
224
+ Array(entries).each_with_index do |e, i|
225
+ check_owner!(e["owner"], "$.entries[#{i}]")
226
+ end
227
+ end
228
+
229
+ def check_owner!(owner, path)
230
+ return if owner.nil?
231
+ return if valid_owner?(owner)
232
+
233
+ raise BadManifest.new(
234
+ "invalid owner '#{owner}' at '#{path}' " \
235
+ "(expected <archetype> or <archetype>:<subject>, " \
236
+ "archetype one of: #{Textus::Role::NAMES.join(", ")})",
237
+ )
238
+ end
239
+
240
+ # The owner-validation rule: an `owner:` token is either a bare archetype
241
+ # (`agent`) or `<archetype>:<subject>` (`human:patrick`). The archetype is
242
+ # gated against the closed Role::NAMES set (so attribution can't smuggle in
243
+ # a name the role side rejects, ADR 0045 D1); the subject is the free-form
244
+ # principal, validated by OWNER_SUBJECT_PATTERN. Split on the FIRST ':'
245
+ # only — a subject may not itself contain ':' (the pattern excludes it), so
246
+ # `human:a:b` is rejected.
247
+ def valid_owner?(token)
248
+ return false unless token.is_a?(String) && !token.empty?
249
+
250
+ archetype, subject = token.split(":", 2)
251
+ return false unless Textus::Role::NAMES.include?(archetype)
252
+ return true if subject.nil?
253
+
254
+ OWNER_SUBJECT_PATTERN.match?(subject)
255
+ end
256
+
257
+ def walk(hash, allowed, path)
258
+ return unless hash.is_a?(Hash)
259
+
260
+ hash.each_key do |k|
261
+ next if allowed.include?(k)
262
+
263
+ raise BadManifest.new("unknown key '#{k}' at '#{path}'")
264
+ end
265
+ end
266
+
267
+ def validate_single_queue!(raw)
268
+ queues = Array(raw["zones"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
269
+ return if queues.size <= 1
270
+
271
+ raise BadManifest.new(
272
+ "at most one zone may declare kind: queue (found: #{queues.join(", ")})",
273
+ )
274
+ end
275
+
276
+ def validate_single_machine!(raw)
277
+ machines = Array(raw["zones"]).select { |z| z["kind"] == "machine" }.map { |z| z["name"] }
278
+ return if machines.size <= 1
279
+
280
+ raise BadManifest.new(
281
+ "at most one zone may declare kind: machine (found: #{machines.join(", ")})",
282
+ )
283
+ end
284
+
285
+ # ADR 0093: retention (drop/archive) is age-based GC; it is invalid on a
286
+ # derived entry (a derived entry regenerates from its source, it isn't aged
287
+ # out). Per ADR 0095 the produce-method is read from source.from on the one
288
+ # Produced kind, so there is no longer a kind to agree against the source.
289
+ # (Replaces validate_upkeep_kinds!.)
290
+ def validate_source_and_retention!(manifest)
291
+ manifest.data.entries.each do |entry|
292
+ retention = manifest.rules.for(entry.key).retention
293
+ next if retention.nil?
294
+ next unless entry.derived?
295
+
296
+ raise BadManifest.new(
297
+ "entry '#{entry.key}': a derived entry regenerates from its source; " \
298
+ "retention (drop/archive) is invalid",
299
+ )
300
+ end
301
+ end
302
+
303
+ # Write authority is derived from capabilities (ADR 0030): a zone of a
304
+ # given kind can only be written by a role that holds the kind's required
305
+ # verb. Reject a manifest declaring a zone whose required verb is held by
306
+ # no role. Capabilities.resolve returns the defaults when `roles:` is nil,
307
+ # so the capability union is all four verbs and every kind is satisfied.
308
+ def validate_zone_kind_consistency!(raw)
309
+ held = Capabilities.resolve(raw["roles"]).values.flatten.uniq
310
+
311
+ Array(raw["zones"]).each_with_index do |z, i|
312
+ verb = KIND_REQUIRES_VERB[z["kind"]]
313
+ next if verb.nil? || held.include?(verb)
314
+
315
+ raise BadManifest.new(
316
+ "zone '#{z["name"]}' (#{z["kind"]}) at '$.zones[#{i}]' " \
317
+ "needs a role with capability '#{verb}'; none declared",
318
+ )
319
+ end
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ class Manifest
3
+ module Schema
4
+ # The closed coordination vocabulary (ADR 0028; five in 0033; unified in
5
+ # 0034; the quarantine + derived ZONE-KINDS folded into one `machine` kind
6
+ # in ADR 0091). Each kind pairs with the capability that authorizes
7
+ # originating bytes in it. ONE source of truth; the derived constants below
8
+ # cannot drift. A BIJECTION again (0090 had two kinds → the converge capability; 0091
9
+ # collapses them, so kind ↔ capability is 1:1).
10
+ module Vocabulary
11
+ LANES = {
12
+ "canon" => "author",
13
+ "workspace" => "keep",
14
+ "machine" => "converge",
15
+ "queue" => "propose",
16
+ }.freeze
17
+
18
+ ZONE_KINDS = LANES.keys.freeze
19
+ CAPABILITIES = LANES.values.uniq.freeze
20
+ KIND_REQUIRES_VERB = LANES
21
+ end
22
+ end
23
+ end
24
+ end