textus 0.26.0 → 0.29.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +111 -67
  3. data/CHANGELOG.md +76 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +75 -38
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +14 -10
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/verb/audit.rb +1 -1
  11. data/lib/textus/cli/verb/boot.rb +1 -1
  12. data/lib/textus/cli/verb/build.rb +2 -2
  13. data/lib/textus/cli/verb/doctor.rb +1 -1
  14. data/lib/textus/cli/verb/hook_run.rb +2 -2
  15. data/lib/textus/cli/verb/put.rb +3 -3
  16. data/lib/textus/cli/verb.rb +6 -6
  17. data/lib/textus/cli.rb +0 -7
  18. data/lib/textus/container.rb +23 -0
  19. data/lib/textus/dispatcher.rb +49 -0
  20. data/lib/textus/doctor/check/audit_log.rb +1 -1
  21. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  22. data/lib/textus/doctor/check/sentinels.rb +10 -8
  23. data/lib/textus/doctor/check.rb +12 -5
  24. data/lib/textus/doctor.rb +7 -7
  25. data/lib/textus/domain/authorizer.rb +2 -2
  26. data/lib/textus/domain/sentinel.rb +9 -65
  27. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  28. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  29. data/lib/textus/domain/staleness.rb +3 -3
  30. data/lib/textus/{application/envelope → envelope/io}/reader.rb +2 -2
  31. data/lib/textus/{application/envelope → envelope/io}/writer.rb +11 -11
  32. data/lib/textus/hooks/context.rb +30 -13
  33. data/lib/textus/hooks/rpc_registry.rb +1 -1
  34. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  35. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  36. data/lib/textus/maintenance/migrate.rb +51 -0
  37. data/lib/textus/maintenance/rule_lint.rb +56 -0
  38. data/lib/textus/maintenance/zone_mv.rb +51 -0
  39. data/lib/textus/maintenance.rb +15 -0
  40. data/lib/textus/manifest/data.rb +4 -3
  41. data/lib/textus/manifest/entry/base.rb +38 -18
  42. data/lib/textus/manifest/entry/derived.rb +6 -6
  43. data/lib/textus/manifest/entry/nested.rb +7 -9
  44. data/lib/textus/manifest/entry/parser.rb +2 -2
  45. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  46. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  47. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  48. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  49. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  50. data/lib/textus/manifest/entry/validators.rb +2 -2
  51. data/lib/textus/manifest/entry.rb +0 -5
  52. data/lib/textus/manifest.rb +1 -6
  53. data/lib/textus/mcp/server.rb +1 -2
  54. data/lib/textus/mcp/session.rb +10 -1
  55. data/lib/textus/mcp/tools.rb +2 -2
  56. data/lib/textus/mcp.rb +1 -1
  57. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  58. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  59. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  60. data/lib/textus/{infra → ports}/clock.rb +1 -1
  61. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  62. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  63. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  64. data/lib/textus/ports/sentinel_store.rb +67 -0
  65. data/lib/textus/ports/storage/file_stat.rb +19 -0
  66. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  67. data/lib/textus/projection.rb +91 -0
  68. data/lib/textus/read/audit.rb +111 -0
  69. data/lib/textus/read/blame.rb +81 -0
  70. data/lib/textus/read/boot.rb +18 -0
  71. data/lib/textus/read/deps.rb +24 -0
  72. data/lib/textus/read/doctor.rb +19 -0
  73. data/lib/textus/read/freshness.rb +101 -0
  74. data/lib/textus/read/get.rb +66 -0
  75. data/lib/textus/read/get_or_refresh.rb +69 -0
  76. data/lib/textus/read/list.rb +15 -0
  77. data/lib/textus/read/policy_explain.rb +37 -0
  78. data/lib/textus/read/published.rb +15 -0
  79. data/lib/textus/read/pulse.rb +89 -0
  80. data/lib/textus/read/rdeps.rb +25 -0
  81. data/lib/textus/read/schema_envelope.rb +16 -0
  82. data/lib/textus/read/stale.rb +17 -0
  83. data/lib/textus/read/uid.rb +20 -0
  84. data/lib/textus/read/validate_all.rb +22 -0
  85. data/lib/textus/read/validator.rb +84 -0
  86. data/lib/textus/read/where.rb +16 -0
  87. data/lib/textus/role_scope.rb +49 -0
  88. data/lib/textus/schema/tools.rb +3 -3
  89. data/lib/textus/store.rb +16 -7
  90. data/lib/textus/version.rb +1 -1
  91. data/lib/textus/write/accept.rb +86 -0
  92. data/lib/textus/write/authority_gate.rb +24 -0
  93. data/lib/textus/write/delete.rb +54 -0
  94. data/lib/textus/write/materializer.rb +48 -0
  95. data/lib/textus/write/mv.rb +123 -0
  96. data/lib/textus/write/publish.rb +66 -0
  97. data/lib/textus/write/put.rb +59 -0
  98. data/lib/textus/write/refresh_all.rb +44 -0
  99. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  100. data/lib/textus/write/refresh_worker.rb +138 -0
  101. data/lib/textus/write/reject.rb +54 -0
  102. data/lib/textus.rb +1 -2
  103. metadata +54 -50
  104. data/lib/textus/application/caps.rb +0 -49
  105. data/lib/textus/application/context.rb +0 -34
  106. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  107. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  108. data/lib/textus/application/maintenance/migrate.rb +0 -59
  109. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  110. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  111. data/lib/textus/application/maintenance.rb +0 -17
  112. data/lib/textus/application/projection.rb +0 -93
  113. data/lib/textus/application/read/audit.rb +0 -106
  114. data/lib/textus/application/read/blame.rb +0 -91
  115. data/lib/textus/application/read/deps.rb +0 -34
  116. data/lib/textus/application/read/freshness.rb +0 -110
  117. data/lib/textus/application/read/get.rb +0 -75
  118. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  119. data/lib/textus/application/read/list.rb +0 -25
  120. data/lib/textus/application/read/policy_explain.rb +0 -47
  121. data/lib/textus/application/read/published.rb +0 -25
  122. data/lib/textus/application/read/pulse.rb +0 -101
  123. data/lib/textus/application/read/rdeps.rb +0 -35
  124. data/lib/textus/application/read/schema_envelope.rb +0 -26
  125. data/lib/textus/application/read/stale.rb +0 -23
  126. data/lib/textus/application/read/uid.rb +0 -30
  127. data/lib/textus/application/read/validate_all.rb +0 -32
  128. data/lib/textus/application/read/validator.rb +0 -86
  129. data/lib/textus/application/read/where.rb +0 -26
  130. data/lib/textus/application/use_case.rb +0 -22
  131. data/lib/textus/application/write/accept.rb +0 -102
  132. data/lib/textus/application/write/authority_gate.rb +0 -26
  133. data/lib/textus/application/write/delete.rb +0 -45
  134. data/lib/textus/application/write/materializer.rb +0 -49
  135. data/lib/textus/application/write/mv.rb +0 -118
  136. data/lib/textus/application/write/publish.rb +0 -96
  137. data/lib/textus/application/write/put.rb +0 -49
  138. data/lib/textus/application/write/refresh_all.rb +0 -63
  139. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  140. data/lib/textus/application/write/refresh_worker.rb +0 -134
  141. data/lib/textus/application/write/reject.rb +0 -62
  142. data/lib/textus/session.rb +0 -84
@@ -0,0 +1,56 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Compare the live manifest's `rules:` block against a candidate
6
+ # YAML string. Returns a Plan describing rule additions/removals/
7
+ # changes. Does NOT write anything.
8
+ class RuleLint
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ @root = container.root
13
+ end
14
+
15
+ def call(candidate_yaml:)
16
+ live_rules = current_rules
17
+ candidate_rules = parse_candidate(candidate_yaml)
18
+
19
+ live_by_match = live_rules.to_h { |r| [r["match"], r] }
20
+ candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
21
+
22
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
23
+ { "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
24
+ end
25
+ (live_by_match.keys - candidate_by_match.keys).each do |m|
26
+ steps << { "op" => "remove_rule", "match" => m }
27
+ end
28
+ (live_by_match.keys & candidate_by_match.keys).each do |m|
29
+ next if live_by_match[m] == candidate_by_match[m]
30
+
31
+ steps << { "op" => "change_rule", "match" => m,
32
+ "from" => live_by_match[m], "to" => candidate_by_match[m] }
33
+ end
34
+
35
+ Plan.new(steps: steps, warnings: [])
36
+ end
37
+
38
+ private
39
+
40
+ def current_rules
41
+ raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
42
+ permitted_classes: [Symbol], aliases: false)
43
+ Array(raw["rules"])
44
+ end
45
+
46
+ def parse_candidate(yaml_text)
47
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
48
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
49
+
50
+ Array(raw["rules"])
51
+ rescue Psych::Exception => e
52
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,51 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Rename a zone — rewrites the manifest's zones[] entry, rewrites
6
+ # the `zone:` field on every entry under the old zone, and moves
7
+ # every file from zones/<old>/ to zones/<new>/.
8
+ class ZoneMv
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ @manifest = container.manifest
13
+ @root = container.root
14
+ end
15
+
16
+ def call(from:, to:, dry_run: false)
17
+ raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
18
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
19
+
20
+ dest_dir = File.join(@root, "zones", to)
21
+ raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
22
+
23
+ affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
24
+
25
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
26
+ steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
27
+
28
+ plan = Plan.new(steps: steps, warnings: [])
29
+ return plan if dry_run
30
+
31
+ rewrite_manifest!(from, to)
32
+ FileUtils.mv(File.join(@root, "zones", from), dest_dir)
33
+ plan
34
+ end
35
+
36
+ private
37
+
38
+ def rewrite_manifest!(from, to)
39
+ path = File.join(@root, "manifest.yaml")
40
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
41
+ raw["zones"].each { |z| z["name"] = to if z["name"] == from }
42
+ raw["entries"].each do |e|
43
+ e["zone"] = to if e["zone"] == from
44
+ e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
45
+ e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
46
+ end
47
+ File.write(path, YAML.dump(raw))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ # Bulk and structural changes to a textus store. Each use case returns
3
+ # a Plan when called with dry_run: true, and applies the plan when
4
+ # called with dry_run: false.
5
+ module Maintenance
6
+ # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
7
+ # use case knows how to apply. Warnings are strings surfaced to
8
+ # the operator (skipped keys, ambiguities).
9
+ Plan = Data.define(:steps, :warnings) do
10
+ def to_h
11
+ { "steps" => steps, "warnings" => warnings }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -41,7 +41,8 @@ module Textus
41
41
  @audit_config = build_audit_config(raw)
42
42
  @role_mapping = RoleKinds.resolve(raw["roles"])
43
43
  # Policy is constructed before entries because Entry validators
44
- # call `entry.in_generator_zone?` which routes through Policy.
44
+ # call `entry.in_generator_zone?(policy)` and similar helpers
45
+ # that take Policy as an argument.
45
46
  @policy = Policy.new(self)
46
47
  @entries = build_entries(raw)
47
48
  validate_declared_keys!
@@ -60,8 +61,8 @@ module Textus
60
61
 
61
62
  def build_entries(raw)
62
63
  Array(raw["entries"]).map do |e|
63
- entry = Manifest::Entry::Parser.call(self, e)
64
- Manifest::Entry::Validators.run_all(entry)
64
+ entry = Manifest::Entry::Parser.call(e)
65
+ Manifest::Entry::Validators.run_all(entry, policy: @policy)
65
66
  entry
66
67
  end.freeze
67
68
  end
@@ -2,11 +2,10 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Base < Entry
5
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest, :publish_to
5
+ attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_to
6
6
 
7
7
  # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
- def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
9
- @manifest = manifest
8
+ def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
10
9
  @raw = raw
11
10
  @key = key
12
11
  @path = path
@@ -18,14 +17,14 @@ module Textus
18
17
  end
19
18
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
20
19
 
21
- def zone_writers
22
- @manifest.policy.zone_writers(@zone)
20
+ def zone_writers(policy)
21
+ policy.zone_writers(@zone)
23
22
  rescue UsageError => e
24
23
  raise UsageError.new("entry '#{@key}': #{e.message}")
25
24
  end
26
25
 
27
- def in_generator_zone? = @manifest.policy.zone_kinds(@zone).include?(:generator)
28
- def in_proposal_zone? = @manifest.policy.zone_kinds(@zone).include?(:proposer)
26
+ def in_generator_zone?(policy) = policy.zone_kinds(@zone).include?(:generator)
27
+ def in_proposal_zone?(policy) = policy.zone_kinds(@zone).include?(:proposer)
29
28
 
30
29
  def nested? = false
31
30
  def derived? = false
@@ -41,11 +40,32 @@ module Textus
41
40
  def publish_each = nil
42
41
  def index_filename = nil
43
42
 
44
- PublishContext = Struct.new(
45
- :repo_root, :manifest, :file_store, :root, :caps, :rpc, :session, :ctx, :bus, :hook_context,
46
- :reader, :emit, # callables: reader.call(key) envelope; emit.call(event, **payload)
47
- keyword_init: true
48
- )
43
+ # Minimal context object passed into entry `publish_via` hooks.
44
+ # Everything beyond the three primitives is derived. Data.define
45
+ # instances are frozen, so we recompute per-call rather than
46
+ # memoizing — RoleScope/Hooks::Context construction is cheap.
47
+ PublishContext = ::Data.define(:container, :call, :reader) do
48
+ def manifest = container.manifest
49
+ def root = container.root
50
+ def repo_root = File.dirname(container.root)
51
+ def events = container.events
52
+
53
+ def hook_context
54
+ Textus::Hooks::Context.new(scope: scope_for_hooks)
55
+ end
56
+
57
+ def emit(event, **payload)
58
+ events.publish(event, ctx: hook_context, **payload)
59
+ end
60
+
61
+ private
62
+
63
+ def scope_for_hooks
64
+ Textus::RoleScope.new(
65
+ container: container, role: call.role, dry_run: call.dry_run,
66
+ )
67
+ end
68
+ end
49
69
 
50
70
  # Subclasses override to customize publish behavior.
51
71
  # Default: copy the stored file to each publish_to target.
@@ -59,12 +79,12 @@ module Textus
59
79
 
60
80
  publish_to.each do |rel|
61
81
  target_abs = File.join(pctx.repo_root, rel)
62
- Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
63
- pctx.emit.call(:file_published,
64
- key: @key,
65
- envelope: envelope,
66
- source: source_path,
67
- target: target_abs)
82
+ Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
83
+ pctx.emit(:file_published,
84
+ key: @key,
85
+ envelope: envelope,
86
+ source: source_path,
87
+ target: target_abs)
68
88
  end
69
89
 
70
90
  { kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
@@ -20,22 +20,22 @@ module Textus
20
20
  def external? = @source.is_a?(External)
21
21
 
22
22
  def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
23
- return nil unless in_generator_zone?
23
+ return nil unless in_generator_zone?(pctx.manifest.policy)
24
24
 
25
- target_path = Textus::Application::Write::Materializer.new(
26
- ctx: pctx.ctx, caps: pctx.caps, rpc: pctx.rpc, session: pctx.session,
25
+ target_path = Textus::Write::Materializer.new(
26
+ container: pctx.container, call: pctx.call,
27
27
  ).run(self)
28
28
 
29
29
  envelope = pctx.reader.call(@key)
30
30
  Array(publish_to).each do |rel|
31
31
  target_abs = File.join(pctx.repo_root, rel)
32
- Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
33
- pctx.emit.call(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
32
+ Textus::Ports::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
33
+ pctx.emit(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
34
34
  end
35
35
 
36
36
  src = @source
37
37
  selects = src.is_a?(Projection) ? Array(src.select).compact : []
38
- pctx.emit.call(:build_completed, key: @key, envelope: envelope, sources: selects)
38
+ pctx.emit(:build_completed, key: @key, envelope: envelope, sources: selects)
39
39
 
40
40
  { kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
41
41
  end
@@ -1,5 +1,3 @@
1
- require_relative "validators/publish_each"
2
-
3
1
  module Textus
4
2
  class Manifest
5
3
  class Entry
@@ -37,7 +35,7 @@ module Textus
37
35
  return nil if @publish_each.nil?
38
36
 
39
37
  leaves = []
40
- @manifest.resolver.enumerate(prefix: @key).each do |row|
38
+ pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
41
39
  next unless row[:manifest_entry].equal?(self)
42
40
  next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
43
41
 
@@ -49,12 +47,12 @@ module Textus
49
47
  )
50
48
  end
51
49
 
52
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
53
- pctx.emit.call(:file_published,
54
- key: row[:key],
55
- envelope: pctx.reader.call(row[:key]),
56
- source: row[:path],
57
- target: target_abs)
50
+ Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
51
+ pctx.emit(:file_published,
52
+ key: row[:key],
53
+ envelope: pctx.reader.call(row[:key]),
54
+ source: row[:path],
55
+ target: target_abs)
58
56
  leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
59
57
  end
60
58
 
@@ -4,7 +4,7 @@ module Textus
4
4
  module Parser
5
5
  COMPUTE_KINDS = %w[projection external].freeze
6
6
 
7
- def self.call(manifest, raw)
7
+ def self.call(raw)
8
8
  key = raw["key"] or raise UsageError.new("manifest entry missing key")
9
9
  path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
10
10
  zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
@@ -14,7 +14,7 @@ module Textus
14
14
  format = resolve_format(raw, path)
15
15
 
16
16
  common = {
17
- manifest: manifest, raw: raw,
17
+ raw: raw,
18
18
  key: key, path: path, zone: zone,
19
19
  schema: raw["schema"], owner: raw["owner"],
20
20
  format: format,
@@ -3,7 +3,7 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module Events
6
- def self.call(entry)
6
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
7
  pubsub_events = Textus::Hooks::EventBus::EVENTS.keys
8
8
  events = entry.events
9
9
  events.each_key do |evt|
@@ -3,7 +3,7 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module FormatMatrix
6
- def self.call(entry)
6
+ def self.call(entry, policy:)
7
7
  begin
8
8
  Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
9
9
  rescue UsageError => e
@@ -17,7 +17,7 @@ module Textus
17
17
  has_template = !entry.template.nil?
18
18
  is_external = entry.derived? && entry.external?
19
19
  is_intake = entry.intake?
20
- return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
20
+ return unless entry.in_generator_zone?(policy) && !has_template && !is_external && !is_intake &&
21
21
  %w[markdown text].include?(entry.format) && !entry.nested?
22
22
 
23
23
  raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
@@ -3,7 +3,7 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module IndexFilename
6
- def self.call(entry)
6
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
7
  # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
8
8
  index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
9
9
  return if index_filename.nil?
@@ -3,10 +3,12 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module InjectBoot
6
- def self.call(entry)
6
+ def self.call(entry, policy:)
7
7
  return unless entry.inject_boot
8
8
 
9
- raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries") unless entry.in_generator_zone?
9
+ unless entry.in_generator_zone?(policy)
10
+ raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries")
11
+ end
10
12
 
11
13
  return unless entry.template.nil?
12
14
 
@@ -7,7 +7,7 @@ module Textus
7
7
  VAR_RE = /\{([a-z]+)\}/
8
8
  REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
9
9
 
10
- def self.call(entry)
10
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
11
11
  # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
12
12
  publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
13
13
  return if publish_each.nil?
@@ -10,8 +10,8 @@ module Textus
10
10
  FormatMatrix,
11
11
  ].freeze
12
12
 
13
- def self.run_all(entry)
14
- REGISTERED.each { |v| v.call(entry) }
13
+ def self.run_all(entry, policy:)
14
+ REGISTERED.each { |v| v.call(entry, policy: policy) }
15
15
  nil
16
16
  end
17
17
  end
@@ -1,11 +1,6 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Entry
4
- # Re-exported for backward compatibility with callers that referenced these
5
- # constants on Entry. Canonical source is the PublishEach validator.
6
- PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
7
- PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
8
-
9
4
  # Populated by each Entry::* subclass at load time.
10
5
  REGISTRY = {}
11
6
  end
@@ -43,17 +43,12 @@ module Textus # rubocop:disable Style/OneClassPerFile
43
43
 
44
44
  def build(raw, root)
45
45
  data = Manifest::Data.parse(raw, root: root)
46
- composition = new(
46
+ new(
47
47
  data: data,
48
48
  resolver: Manifest::Resolver.new(data),
49
49
  policy: data.policy,
50
50
  rules: Manifest::Rules.parse(raw["rules"] || []),
51
51
  )
52
- # Re-point entries' back-reference from Data to the composition
53
- # record. Entries call `@manifest.policy.*` / `@manifest.resolver.*`
54
- # at use time (see Entry::Base, Entry::Nested).
55
- data.entries.each { |e| e.instance_variable_set(:@manifest, composition) }
56
- composition
57
52
  end
58
53
 
59
54
  def check_version!(raw, source)
@@ -1,5 +1,4 @@
1
1
  require "json"
2
- require "digest"
3
2
 
4
3
  module Textus
5
4
  module MCP
@@ -107,7 +106,7 @@ module Textus
107
106
  end
108
107
 
109
108
  def manifest_etag
110
- Digest::SHA256.hexdigest(File.read(File.join(@store.root, "manifest.yaml")))
109
+ @store.file_store.etag(File.join(@store.root, "manifest.yaml"))
111
110
  end
112
111
 
113
112
  def emit_result(rid, result)
@@ -23,9 +23,18 @@ module Textus
23
23
  return if observed_etag == @manifest_etag
24
24
 
25
25
  raise ContractDrift.new(
26
- "manifest changed (was #{@manifest_etag[0, 8]}, now #{observed_etag[0, 8]}); re-run boot",
26
+ "manifest changed (was #{short_etag(@manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
27
27
  )
28
28
  end
29
+
30
+ private
31
+
32
+ # First 8 hex chars after the "sha256:" prefix — a stable short id for
33
+ # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
34
+ # a no-op when the prefix is absent).
35
+ def short_etag(etag)
36
+ etag.to_s.delete_prefix("sha256:")[0, 8]
37
+ end
29
38
  end
30
39
  end
31
40
  end
@@ -17,11 +17,11 @@ module Textus
17
17
  end
18
18
 
19
19
  def ops_for(session, store)
20
- store.session(role: session.role)
20
+ store.as(session.role)
21
21
  end
22
22
 
23
23
  REGISTRY = {
24
- "boot" => ->(_s, store, _a) { Textus::Boot.run(Textus::Session.for(store)) },
24
+ "boot" => ->(_s, store, _a) { store.boot },
25
25
 
26
26
  "find" => lambda do |s, store, args|
27
27
  ops_for(s, store).list(zone: args["zone"], prefix: args["prefix"])
data/lib/textus/mcp.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  module Textus
2
2
  # The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
3
- # Wraps Textus::Session as auto-derived tools. See ADR 0015.
3
+ # Wraps Textus::RoleScope as auto-derived tools. See ADR 0015.
4
4
  module MCP
5
5
  end
6
6
  end
@@ -3,7 +3,7 @@ require "json"
3
3
  require "time"
4
4
 
5
5
  module Textus
6
- module Infra
6
+ module Ports
7
7
  class AuditLog
8
8
  DEFAULT_MAX_SIZE = 10_485_760
9
9
  DEFAULT_KEEP = 5
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Infra
4
+ module Ports
5
5
  # Writes an "event_error" audit row when a user hook raises during
6
6
  # Hooks::EventBus publish. Attached at Store boot.
7
7
  #
@@ -11,7 +11,7 @@ module Textus
11
11
  # event subscribers should be able to filter by key glob).
12
12
  #
13
13
  # Lifecycle audit rows for verb: "put" / "delete" / "rename" are written
14
- # by Application::Envelope::Writer directly (it owns the
14
+ # by Envelope::IO::Writer directly (it owns the
15
15
  # audit-append-as-final-step invariant); this subscriber covers the
16
16
  # hook-failure case the writer never sees.
17
17
  class AuditSubscriber
@@ -3,7 +3,7 @@ require "socket"
3
3
  require "time"
4
4
 
5
5
  module Textus
6
- module Infra
6
+ module Ports
7
7
  class BuildLock
8
8
  LOCK_FILENAME = ".build.lock"
9
9
  MAX_HOLDER_BYTES = 512
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Infra
2
+ module Ports
3
3
  module Clock
4
4
  module_function
5
5
 
@@ -1,21 +1,21 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Infra
4
+ module Ports
5
5
  # Publishes built artifacts from the store to repo-relative consumer paths.
6
6
  # Publish = copy + sentinel. The in-store file is already the consumer-shaped
7
7
  # artifact; no parsing or stripping.
8
8
  #
9
- # Sentinel I/O is delegated to Textus::Domain::Sentinel. Sentinels live under
10
- # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
11
- # consumer directories aren't polluted with `.textus-managed.json` siblings.
9
+ # Sentinel I/O is delegated to Textus::Ports::SentinelStore. Sentinels live
10
+ # under `<store_root>/sentinels/` and mirror the target's repo-relative layout
11
+ # so consumer directories aren't polluted with `.textus-managed.json` siblings.
12
12
  module Publisher
13
13
  def self.publish(source:, target:, store_root:)
14
14
  FileUtils.mkdir_p(File.dirname(target))
15
15
  refuse_if_unmanaged(target, store_root)
16
16
  File.delete(target) if File.symlink?(target)
17
17
  FileUtils.cp(source, target)
18
- Textus::Domain::Sentinel.write!(target: target, source: source, store_root: store_root)
18
+ Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
19
19
  end
20
20
 
21
21
  def self.refuse_if_unmanaged(target, store_root)
@@ -26,7 +26,7 @@ module Textus
26
26
  end
27
27
 
28
28
  def self.managed?(target, store_root)
29
- File.exist?(Textus::Domain::Sentinel.sentinel_path(target, store_root))
29
+ File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
30
30
  end
31
31
  end
32
32
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Infra
2
+ module Ports
3
3
  module Refresh
4
4
  module Detached
5
5
  module_function
@@ -16,12 +16,12 @@ module Textus
16
16
  $stdout.reopen(File::NULL, "w")
17
17
  $stderr.reopen(File::NULL, "w")
18
18
 
19
- lock = Textus::Infra::Refresh::Lock.new(root: store_root, key: key)
19
+ lock = Textus::Ports::Refresh::Lock.new(root: store_root, key: key)
20
20
  exit(0) unless lock.try_acquire
21
21
 
22
22
  begin
23
23
  store = Textus::Store.new(store_root)
24
- store.session(role: "runner").refresh(key)
24
+ store.as("runner").refresh(key)
25
25
  rescue StandardError
26
26
  # Already logged via :refresh_failed; exit cleanly.
27
27
  ensure
@@ -1,7 +1,7 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Infra
4
+ module Ports
5
5
  module Refresh
6
6
  class Lock
7
7
  def initialize(root:, key:)