textus 0.50.0 → 0.51.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +174 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +13 -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.rb +1 -3
  14. data/lib/textus/dispatcher.rb +1 -3
  15. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  16. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  17. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  18. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  19. data/lib/textus/doctor/check/sentinels.rb +2 -2
  20. data/lib/textus/doctor/check/templates.rb +13 -11
  21. data/lib/textus/doctor.rb +0 -2
  22. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  23. data/lib/textus/domain/freshness/verdict.rb +28 -6
  24. data/lib/textus/domain/freshness.rb +4 -33
  25. data/lib/textus/domain/policy/base_guards.rb +1 -1
  26. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  27. data/lib/textus/domain/policy/publish_target.rb +34 -0
  28. data/lib/textus/domain/policy/retention.rb +29 -0
  29. data/lib/textus/domain/policy/source.rb +79 -0
  30. data/lib/textus/domain/retention/sweep.rb +57 -0
  31. data/lib/textus/domain/retention.rb +11 -0
  32. data/lib/textus/errors.rb +4 -4
  33. data/lib/textus/hooks/builtin.rb +5 -5
  34. data/lib/textus/hooks/catalog.rb +8 -7
  35. data/lib/textus/hooks/context.rb +5 -10
  36. data/lib/textus/init/templates/machine_intake.rb +4 -4
  37. data/lib/textus/init.rb +47 -47
  38. data/lib/textus/key/matching.rb +24 -0
  39. data/lib/textus/maintenance/reconcile.rb +160 -0
  40. data/lib/textus/manifest/capabilities.rb +1 -1
  41. data/lib/textus/manifest/data.rb +2 -2
  42. data/lib/textus/manifest/entry/base.rb +28 -9
  43. data/lib/textus/manifest/entry/nested.rb +3 -4
  44. data/lib/textus/manifest/entry/parser.rb +25 -21
  45. data/lib/textus/manifest/entry/produced.rb +56 -0
  46. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  47. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  48. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  49. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  50. data/lib/textus/manifest/entry/validators.rb +0 -1
  51. data/lib/textus/manifest/policy.rb +16 -4
  52. data/lib/textus/manifest/resolver.rb +10 -4
  53. data/lib/textus/manifest/rules.rb +37 -36
  54. data/lib/textus/manifest/schema/keys.rb +98 -0
  55. data/lib/textus/manifest/schema/validator.rb +324 -0
  56. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  57. data/lib/textus/manifest/schema.rb +27 -247
  58. data/lib/textus/manifest.rb +5 -3
  59. data/lib/textus/mcp/server.rb +1 -1
  60. data/lib/textus/ports/audit_log.rb +6 -0
  61. data/lib/textus/ports/build_lock.rb +6 -0
  62. data/lib/textus/ports/clock.rb +4 -3
  63. data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
  64. data/lib/textus/ports/publisher.rb +11 -7
  65. data/lib/textus/produce/acquire/handler.rb +29 -0
  66. data/lib/textus/produce/acquire/intake.rb +130 -0
  67. data/lib/textus/produce/acquire/projection.rb +127 -0
  68. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  69. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  70. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  71. data/lib/textus/produce/acquire/serializer.rb +17 -0
  72. data/lib/textus/produce/engine.rb +143 -0
  73. data/lib/textus/produce/events.rb +36 -0
  74. data/lib/textus/produce/render.rb +23 -0
  75. data/lib/textus/projection.rb +17 -6
  76. data/lib/textus/read/deps.rb +3 -3
  77. data/lib/textus/read/freshness.rb +61 -31
  78. data/lib/textus/read/get.rb +20 -102
  79. data/lib/textus/read/rdeps.rb +3 -3
  80. data/lib/textus/read/rule_explain.rb +41 -23
  81. data/lib/textus/read/rule_list.rb +25 -8
  82. data/lib/textus/read/validate_all.rb +14 -0
  83. data/lib/textus/role.rb +2 -1
  84. data/lib/textus/schemas.rb +8 -0
  85. data/lib/textus/store.rb +1 -0
  86. data/lib/textus/version.rb +1 -1
  87. data/lib/textus/write/put.rb +1 -1
  88. metadata +23 -30
  89. data/lib/textus/builder/pipeline.rb +0 -88
  90. data/lib/textus/builder/renderer/json.rb +0 -45
  91. data/lib/textus/builder/renderer/markdown.rb +0 -24
  92. data/lib/textus/builder/renderer/text.rb +0 -14
  93. data/lib/textus/builder/renderer/yaml.rb +0 -45
  94. data/lib/textus/builder/renderer.rb +0 -17
  95. data/lib/textus/cli/verb/boot.rb +0 -14
  96. data/lib/textus/cli/verb/build.rb +0 -15
  97. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  98. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  99. data/lib/textus/domain/freshness/policy.rb +0 -18
  100. data/lib/textus/domain/lifecycle.rb +0 -83
  101. data/lib/textus/domain/outcome.rb +0 -10
  102. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  103. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  104. data/lib/textus/domain/staleness.rb +0 -29
  105. data/lib/textus/maintenance/tend.rb +0 -110
  106. data/lib/textus/manifest/entry/derived.rb +0 -67
  107. data/lib/textus/manifest/entry/intake.rb +0 -31
  108. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  109. data/lib/textus/mcp/tools.rb +0 -14
  110. data/lib/textus/ports/fetch/detached.rb +0 -52
  111. data/lib/textus/ports/fetch/lock.rb +0 -44
  112. data/lib/textus/write/build.rb +0 -90
  113. data/lib/textus/write/fetch_events.rb +0 -42
  114. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  115. data/lib/textus/write/fetch_worker.rb +0 -127
  116. data/lib/textus/write/intake_fetch.rb +0 -25
  117. data/lib/textus/write/materializer.rb +0 -51
@@ -0,0 +1,56 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ # A produced entry (ADR 0095) — anything with a `source:`. The produce
5
+ # method (intake/derived/external) is read from source.from; there is no
6
+ # separate kind for it. Merges the former Derived + Intake classes.
7
+ class Produced < Base
8
+ attr_reader :source, :events
9
+
10
+ def initialize(source:, events: {}, **rest)
11
+ super(**rest)
12
+ @source = source
13
+ @events = events || {}
14
+ end
15
+
16
+ def intake? = @source.kind == :intake
17
+ def derived? = @source.kind == :derived
18
+ def external? = @source.external?
19
+ def projection? = @source.projection?
20
+ def nested? = !!@raw["nested"]
21
+ def handler = @source.handler
22
+ def config = @source.config
23
+
24
+ KIND = :produced
25
+
26
+ # ADR 0094/0095: projection (from: project) sources build their DATA
27
+ # artifact here, then publish via the ONE shared mode (Publish::ToPaths).
28
+ # Intake bytes come from Produce::Acquire::Intake and command (external) bytes from the
29
+ # out-of-band runner — neither builds, but both still publish their
30
+ # existing store bytes through the same mode. A projection entry with no
31
+ # targets is a terminal data node: it produced data, so report :built
32
+ # even though nothing was emitted.
33
+ def publish_via(pctx, prefix: nil)
34
+ built = false
35
+ if projection?
36
+ Textus::Produce::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
37
+ built = true
38
+ pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
39
+ end
40
+
41
+ emitted = publish_mode.publish(pctx, prefix: prefix)
42
+ return emitted if emitted
43
+ return nil unless built
44
+
45
+ { kind: :built, value: { "key" => @key, "path" => Key::Path.resolve(pctx.manifest.data, self), "published_to" => [] } }
46
+ end
47
+
48
+ def self.from_raw(common, raw)
49
+ new(source: Parser.parse_source(raw, common[:key]), events: raw["events"] || {}, **common)
50
+ end
51
+
52
+ Entry::REGISTRY[KIND] = self
53
+ end
54
+ end
55
+ end
56
+ end
@@ -9,9 +9,10 @@ module Textus
9
9
  # shared shape — Tree always walks at `base` and honors `ignore` in the
10
10
  # prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
11
11
  class SubtreeMirror
12
- def initialize(entry, pctx)
13
- @entry = entry
14
- @pctx = pctx
12
+ def initialize(entry, pctx, publisher: Textus::Ports::Publisher.new)
13
+ @entry = entry
14
+ @pctx = pctx
15
+ @publisher = publisher
15
16
  end
16
17
 
17
18
  # base: store dir the entry owns — the root `ignored?` globs are
@@ -40,8 +41,8 @@ module Textus
40
41
  next nil if @entry.ignored?(relative(src, base))
41
42
 
42
43
  dst = File.join(target_dir, relative(src, walk_root))
43
- Textus::Ports::Publisher.publish(source: src, target: dst, store_root: @pctx.root)
44
- @pctx.emit(:file_published, key: key, envelope: envelope, source: src, target: dst)
44
+ @publisher.publish(source: src, target: dst, store_root: @pctx.root)
45
+ @pctx.emit(:entry_published, key: key, envelope: envelope, source: src, target: dst)
45
46
  { "key" => key, "source" => src, "target" => dst }
46
47
  end
47
48
  end
@@ -57,7 +58,7 @@ module Textus
57
58
  next nil if kept.include?(abs)
58
59
  next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
59
60
 
60
- Textus::Ports::Publisher.unpublish(target: managed, store_root: @pctx.root)
61
+ @publisher.unpublish(target: managed, store_root: @pctx.root)
61
62
  managed
62
63
  end
63
64
  end
@@ -1,24 +1,75 @@
1
+ require "tempfile"
2
+
1
3
  module Textus
2
4
  class Manifest
3
5
  class Entry
4
6
  module Publish
5
- # publish.to: copy the entry's one stored file to each fixed repo path.
6
- # The behaviour of any entry that declares `publish: { to: [...] }`.
7
+ # publish.to: render or copy the entry's stored data to each fixed repo path.
8
+ # The behaviour of any entry that declares `publish: [{ to: ... }, ...]`.
9
+ # ADR 0094: iterates publish_targets (to-targets), rendering through a
10
+ # template when the target declares one, or copying verbatim otherwise.
7
11
  class ToPaths < Mode
8
- def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
9
- targets = Array(entry.publish_to)
12
+ def initialize(entry, publisher: Textus::Ports::Publisher.new)
13
+ super(entry)
14
+ @publisher = publisher
15
+ end
16
+
17
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
18
+ targets = entry.publish_targets.select(&:to_target?)
10
19
  return nil if targets.empty?
11
20
 
12
- source_path = pctx.manifest.resolver.resolve(entry.key).path
13
- envelope = pctx.reader.call(entry.key)
21
+ data_path = pctx.manifest.resolver.resolve(entry.key).path
22
+ envelope = pctx.reader.call(entry.key)
23
+ renderer = Textus::Produce::Render.new(template_loader: ->(n) { pctx.read_template(n) })
24
+ content = nil # parsed lazily; the data's `content` (always _meta-free)
14
25
 
15
- targets.each do |rel|
16
- target_abs = File.join(pctx.repo_root, rel)
17
- Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
18
- pctx.emit(:file_published, key: entry.key, envelope: envelope, source: source_path, target: target_abs)
26
+ targets.each do |t|
27
+ if t.renders?
28
+ content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
29
+ publish_bytes(render_bytes(t, content, renderer, pctx), entry.key, t, pctx, data_path, envelope)
30
+ elsif strip_meta?(entry)
31
+ content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
32
+ bytes = Textus::Entry.for_format(entry.format).serialize(meta: {}, body: "", content: content)
33
+ publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
34
+ else
35
+ # opaque / command / non-structured — publish the stored file as-is
36
+ target_abs = File.join(pctx.repo_root, t.to)
37
+ @publisher.publish(source: data_path, target: target_abs, store_root: pctx.root)
38
+ pctx.emit(:entry_published, key: entry.key, envelope: envelope, source: data_path, target: target_abs)
39
+ end
19
40
  end
20
41
 
21
- { kind: :built, value: { "key" => entry.key, "path" => source_path, "published_to" => targets } }
42
+ { kind: :built, value: { "key" => entry.key, "path" => data_path, "published_to" => targets.map(&:to) } }
43
+ end
44
+
45
+ private
46
+
47
+ # A structured-data entry that textus owns: its `_meta` stays in the
48
+ # store, so the published file is the re-serialized meta-free content.
49
+ # An external (command) entry is opaque — never parse/re-serialize it.
50
+ def strip_meta?(entry)
51
+ !entry.external? && %w[json yaml].include?(entry.format.to_s)
52
+ end
53
+
54
+ def render_bytes(target, content, renderer, pctx)
55
+ boot = target.inject_boot ? Textus::Boot.build(container: pctx.container) : nil
56
+ renderer.bytes_for(target: target, data: content, boot: boot)
57
+ end
58
+
59
+ # Write bytes to a system temp, publish (recording the persistent data
60
+ # file as the sentinel source), then remove the temp — the store is
61
+ # never polluted with render artifacts.
62
+ def publish_bytes(bytes, key, target, pctx, data_path, envelope)
63
+ target_abs = File.join(pctx.repo_root, target.to)
64
+ Tempfile.create(["textus-publish", File.extname(target.to)]) do |f|
65
+ f.binmode
66
+ f.write(bytes)
67
+ f.flush
68
+ @publisher.publish(
69
+ source: f.path, target: target_abs, store_root: pctx.root, provenance_source: data_path,
70
+ )
71
+ end
72
+ pctx.emit(:entry_published, key: key, envelope: envelope, source: data_path, target: target_abs)
22
73
  end
23
74
  end
24
75
  end
@@ -3,24 +3,16 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module FormatMatrix
6
- def self.call(entry, policy:)
6
+ def self.call(entry, policy:) # rubocop:disable Lint/UnusedMethodArgument
7
7
  begin
8
8
  Textus::Entry.for_format(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
12
12
 
13
- if entry.format == "text" && !entry.schema.nil?
14
- raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
15
- end
16
-
17
- has_template = !entry.template.nil?
18
- is_external = entry.derived? && entry.external?
19
- is_intake = entry.intake?
20
- return unless entry.in_generator_zone?(policy) && !has_template && !is_external && !is_intake &&
21
- %w[markdown text].include?(entry.format) && !entry.nested?
13
+ return unless entry.format == "text" && !entry.schema.nil?
22
14
 
23
- raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
15
+ raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
24
16
  end
25
17
  end
26
18
  end
@@ -14,7 +14,9 @@ module Textus
14
14
  module Publish
15
15
  def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
16
16
  unless entry.nested?
17
- raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.raw.dig("publish", "tree")
17
+ # ADR 0094: publish: is now a list; use publish_tree (derived reader)
18
+ # rather than raw.dig("publish", "tree") which breaks on an Array.
19
+ raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.publish_tree
18
20
 
19
21
  return
20
22
  end
@@ -5,7 +5,6 @@ module Textus
5
5
  REGISTERED = [
6
6
  Events,
7
7
  Publish,
8
- InjectBoot,
9
8
  Ignore,
10
9
  FormatMatrix,
11
10
  ].freeze
@@ -7,7 +7,7 @@ module Textus
7
7
  # (Schema::KIND_REQUIRES_VERB) and a role may write a zone iff its caps
8
8
  # include that verb (verb_for_zone, roles_with_capability). Derived /
9
9
  # proposal-queue status is authoritative via the declared-kind family
10
- # (declared_kind, derived_zone?, queue_zone?, queue_zone).
10
+ # (declared_kind, derived_entry?, queue_zone?, queue_zone).
11
11
  class Policy
12
12
  def initialize(data)
13
13
  @data = data
@@ -72,9 +72,21 @@ module Textus
72
72
  @data.declared_zone_kinds.key(:queue)
73
73
  end
74
74
 
75
- # A zone is derived iff it declares kind: derived.
76
- def derived_zone?(zone_name)
77
- declared_kind(zone_name) == :derived
75
+ # ADR 0091: derived-ness is a property of the ENTRY, not its zone (one
76
+ # machine zone holds both intake and derived entries). Resolve the entry
77
+ # and ask it directly. Returns false if entries are not yet built
78
+ # (validator phase during Data#initialize) — validators must not rely on
79
+ # cross-entry state during construction.
80
+ def derived_entry?(key)
81
+ return false if @data.entries.nil?
82
+
83
+ entry = @data.entries.find { |e| e.key == key } or return false
84
+ entry.derived?
85
+ end
86
+
87
+ # The single zone declaring kind: machine, or nil.
88
+ def machine_zone
89
+ @data.declared_zone_kinds.key(:machine)
78
90
  end
79
91
 
80
92
  # A zone is a proposal queue iff it declares kind: queue.
@@ -30,8 +30,10 @@ module Textus
30
30
  []
31
31
  end
32
32
 
33
- def enumerate(prefix: nil)
34
- out = @data.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
33
+ def enumerate(prefix: nil, include_keyless: false)
34
+ out = @data.entries.flat_map do |entry|
35
+ nested_entry?(entry) ? enumerate_nested(entry, include_keyless: include_keyless) : enumerate_leaf(entry)
36
+ end
35
37
  out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
36
38
  out.sort_by { |row| row[:key] }
37
39
  end
@@ -62,10 +64,14 @@ module Textus
62
64
  File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
63
65
  end
64
66
 
65
- def enumerate_nested(entry)
67
+ def enumerate_nested(entry, include_keyless: false)
66
68
  # publish_tree mirrors opaque payload by path — its files are never
67
69
  # enumerated as keys (ADR 0047). Ask the resolved mode, not the path.
68
- return [] if entry.publish_mode.keyless?
70
+ # The `include_keyless:` override is used only by the projection lister
71
+ # so that `from: project` selects can read source data from keyless
72
+ # nested entries (e.g. knowledge.decisions) without exposing them as
73
+ # addressable store keys in the public `list` surface.
74
+ return [] if entry.publish_mode.keyless? && !include_keyless
69
75
 
70
76
  base = File.join(@data.root, "zones", entry.path)
71
77
  return [] unless File.directory?(base)
@@ -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 on_write 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