textus 0.26.0 → 0.30.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +118 -68
  3. data/CHANGELOG.md +132 -0
  4. data/README.md +61 -19
  5. data/SPEC.md +107 -46
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +18 -12
  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 -6
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +1 -1
  18. data/lib/textus/cli/verb.rb +6 -6
  19. data/lib/textus/cli.rb +19 -23
  20. data/lib/textus/container.rb +23 -0
  21. data/lib/textus/dispatcher.rb +57 -0
  22. data/lib/textus/doctor/check/audit_log.rb +1 -1
  23. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  24. data/lib/textus/doctor/check/sentinels.rb +10 -8
  25. data/lib/textus/doctor/check.rb +15 -5
  26. data/lib/textus/doctor.rb +7 -7
  27. data/lib/textus/domain/authorizer.rb +2 -2
  28. data/lib/textus/domain/duration.rb +22 -0
  29. data/lib/textus/domain/policy/refresh.rb +1 -15
  30. data/lib/textus/domain/policy/retention.rb +26 -0
  31. data/lib/textus/domain/retention.rb +44 -0
  32. data/lib/textus/domain/sentinel.rb +9 -65
  33. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  34. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  35. data/lib/textus/domain/staleness.rb +3 -3
  36. data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
  37. data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
  38. data/lib/textus/hooks/context.rb +30 -13
  39. data/lib/textus/hooks/event_bus.rb +8 -20
  40. data/lib/textus/hooks/rpc_registry.rb +9 -35
  41. data/lib/textus/hooks/signature.rb +31 -0
  42. data/lib/textus/init.rb +7 -6
  43. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  44. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  45. data/lib/textus/maintenance/migrate.rb +51 -0
  46. data/lib/textus/maintenance/rule_lint.rb +56 -0
  47. data/lib/textus/maintenance/zone_mv.rb +51 -0
  48. data/lib/textus/maintenance.rb +15 -0
  49. data/lib/textus/manifest/data.rb +9 -4
  50. data/lib/textus/manifest/entry/base.rb +38 -18
  51. data/lib/textus/manifest/entry/derived.rb +6 -6
  52. data/lib/textus/manifest/entry/nested.rb +7 -9
  53. data/lib/textus/manifest/entry/parser.rb +2 -2
  54. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  55. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  56. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  57. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  58. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  59. data/lib/textus/manifest/entry/validators.rb +2 -2
  60. data/lib/textus/manifest/entry.rb +0 -5
  61. data/lib/textus/manifest/policy.rb +34 -7
  62. data/lib/textus/manifest/rules.rb +10 -1
  63. data/lib/textus/manifest/schema.rb +54 -4
  64. data/lib/textus/manifest.rb +4 -8
  65. data/lib/textus/mcp/server.rb +2 -11
  66. data/lib/textus/mcp/session.rb +13 -20
  67. data/lib/textus/mcp/tools.rb +2 -2
  68. data/lib/textus/mcp.rb +1 -1
  69. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  70. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  71. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  72. data/lib/textus/{infra → ports}/clock.rb +1 -1
  73. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  74. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  75. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  76. data/lib/textus/ports/sentinel_store.rb +67 -0
  77. data/lib/textus/ports/storage/file_stat.rb +19 -0
  78. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  79. data/lib/textus/projection.rb +91 -0
  80. data/lib/textus/read/audit.rb +111 -0
  81. data/lib/textus/read/blame.rb +81 -0
  82. data/lib/textus/read/boot.rb +18 -0
  83. data/lib/textus/read/deps.rb +24 -0
  84. data/lib/textus/read/doctor.rb +19 -0
  85. data/lib/textus/read/freshness.rb +101 -0
  86. data/lib/textus/read/get.rb +66 -0
  87. data/lib/textus/read/get_or_refresh.rb +69 -0
  88. data/lib/textus/read/list.rb +15 -0
  89. data/lib/textus/read/policy_explain.rb +42 -0
  90. data/lib/textus/read/published.rb +15 -0
  91. data/lib/textus/read/pulse.rb +89 -0
  92. data/lib/textus/read/rdeps.rb +25 -0
  93. data/lib/textus/read/retainable.rb +17 -0
  94. data/lib/textus/read/schema_envelope.rb +16 -0
  95. data/lib/textus/read/stale.rb +17 -0
  96. data/lib/textus/read/uid.rb +20 -0
  97. data/lib/textus/read/validate_all.rb +22 -0
  98. data/lib/textus/read/validator.rb +84 -0
  99. data/lib/textus/read/where.rb +16 -0
  100. data/lib/textus/role_scope.rb +50 -0
  101. data/lib/textus/schema/tools.rb +3 -3
  102. data/lib/textus/store.rb +16 -7
  103. data/lib/textus/version.rb +1 -1
  104. data/lib/textus/write/accept.rb +86 -0
  105. data/lib/textus/write/authority_gate.rb +24 -0
  106. data/lib/textus/write/delete.rb +40 -0
  107. data/lib/textus/write/intake_fetch.rb +23 -0
  108. data/lib/textus/write/materializer.rb +48 -0
  109. data/lib/textus/write/mv.rb +113 -0
  110. data/lib/textus/write/publish.rb +66 -0
  111. data/lib/textus/write/put.rb +45 -0
  112. data/lib/textus/write/refresh_all.rb +44 -0
  113. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  114. data/lib/textus/write/refresh_worker.rb +124 -0
  115. data/lib/textus/write/reject.rb +54 -0
  116. data/lib/textus/write/retention_sweep.rb +55 -0
  117. data/lib/textus.rb +1 -2
  118. metadata +62 -50
  119. data/lib/textus/application/caps.rb +0 -49
  120. data/lib/textus/application/context.rb +0 -34
  121. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  122. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  123. data/lib/textus/application/maintenance/migrate.rb +0 -59
  124. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  125. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  126. data/lib/textus/application/maintenance.rb +0 -17
  127. data/lib/textus/application/projection.rb +0 -93
  128. data/lib/textus/application/read/audit.rb +0 -106
  129. data/lib/textus/application/read/blame.rb +0 -91
  130. data/lib/textus/application/read/deps.rb +0 -34
  131. data/lib/textus/application/read/freshness.rb +0 -110
  132. data/lib/textus/application/read/get.rb +0 -75
  133. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  134. data/lib/textus/application/read/list.rb +0 -25
  135. data/lib/textus/application/read/policy_explain.rb +0 -47
  136. data/lib/textus/application/read/published.rb +0 -25
  137. data/lib/textus/application/read/pulse.rb +0 -101
  138. data/lib/textus/application/read/rdeps.rb +0 -35
  139. data/lib/textus/application/read/schema_envelope.rb +0 -26
  140. data/lib/textus/application/read/stale.rb +0 -23
  141. data/lib/textus/application/read/uid.rb +0 -30
  142. data/lib/textus/application/read/validate_all.rb +0 -32
  143. data/lib/textus/application/read/validator.rb +0 -86
  144. data/lib/textus/application/read/where.rb +0 -26
  145. data/lib/textus/application/use_case.rb +0 -22
  146. data/lib/textus/application/write/accept.rb +0 -102
  147. data/lib/textus/application/write/authority_gate.rb +0 -26
  148. data/lib/textus/application/write/delete.rb +0 -45
  149. data/lib/textus/application/write/materializer.rb +0 -49
  150. data/lib/textus/application/write/mv.rb +0 -118
  151. data/lib/textus/application/write/publish.rb +0 -96
  152. data/lib/textus/application/write/put.rb +0 -49
  153. data/lib/textus/application/write/refresh_all.rb +0 -63
  154. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  155. data/lib/textus/application/write/refresh_worker.rb +0 -134
  156. data/lib/textus/application/write/reject.rb +0 -62
  157. data/lib/textus/session.rb +0 -84
@@ -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.derived_zone?(@zone)
27
+ def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
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
@@ -2,11 +2,13 @@ module Textus
2
2
  class Manifest
3
3
  # Authority over zones and roles derived from a Manifest::Data snapshot.
4
4
  # Encapsulates the lookups previously living on Manifest itself
5
- # (zone_writers, zone_kinds, permission_for, role_kind, roles_with_kind).
5
+ # (zone_writers, permission_for, role_kind, roles_with_kind). Derived /
6
+ # proposal-queue status is authoritative via the declared-kind family
7
+ # (declared_kind, derived_zone?, queue_zone?, queue_zone), not inferred
8
+ # from writers.
6
9
  class Policy
7
10
  def initialize(data)
8
11
  @data = data
9
- @zone_kinds_cache = {}
10
12
  end
11
13
 
12
14
  def zone_writers(zone_name)
@@ -25,11 +27,24 @@ module Textus
25
27
  )
26
28
  end
27
29
 
28
- def zone_kinds(zone_name)
29
- @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
30
- k = role_kind(w)
31
- acc << k if k
32
- end.freeze
30
+ # The kind declared on a zone in the manifest, or nil if undeclared.
31
+ def declared_kind(zone_name)
32
+ @data.declared_zone_kinds[zone_name]
33
+ end
34
+
35
+ # The single zone declaring `kind: queue`, or nil. Schema guarantees <=1.
36
+ def queue_zone
37
+ @data.declared_zone_kinds.key(:queue)
38
+ end
39
+
40
+ # A zone is derived iff it declares kind: derived.
41
+ def derived_zone?(zone_name)
42
+ declared_kind(zone_name) == :derived
43
+ end
44
+
45
+ # A zone is a proposal queue iff it declares kind: queue.
46
+ def queue_zone?(zone_name)
47
+ declared_kind(zone_name) == :queue
33
48
  end
34
49
 
35
50
  def role_mapping
@@ -43,6 +58,18 @@ module Textus
43
58
  def roles_with_kind(kind)
44
59
  @data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
45
60
  end
61
+
62
+ # The zone a proposer role writes proposals into: the single zone that
63
+ # declares kind: queue, when the role can write it. Returns nil if there
64
+ # is no queue zone or the role cannot write it.
65
+ def propose_zone_for(role)
66
+ return nil if role.nil?
67
+
68
+ q = queue_zone
69
+ return nil unless q && zone_writers(q).include?(role)
70
+
71
+ q
72
+ end
46
73
  end
47
74
  end
48
75
  end
@@ -51,7 +51,7 @@ module Textus
51
51
  @refresh = parse_refresh(raw["refresh"])
52
52
  @handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
53
53
  @promote = parse_promotion(raw["promotion"])
54
- @retention = raw["retention"] # reserved — passthrough only
54
+ @retention = parse_retention(raw["retention"])
55
55
  end
56
56
 
57
57
  private
@@ -80,6 +80,15 @@ module Textus
80
80
 
81
81
  Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
82
82
  end
83
+
84
+ def parse_retention(h)
85
+ return nil if h.nil?
86
+
87
+ Textus::Domain::Policy::Retention.new(
88
+ expire_after: h["expire_after"],
89
+ archive_after: h["archive_after"],
90
+ )
91
+ end
83
92
  end
84
93
  end
85
94
  end
@@ -4,8 +4,14 @@ module Textus
4
4
  ROOT_KEYS = %w[version roles zones entries rules audit].freeze
5
5
  ROLE_KEYS = %w[name kind].freeze
6
6
  ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
7
- ZONE_KEYS = %w[name write_policy read_policy].freeze
8
- ENTRY_KEYS = %w[
7
+ ZONE_KEYS = %w[name kind write_policy read_policy].freeze
8
+ ZONE_KINDS = %w[origin quarantine queue derived].freeze
9
+ KIND_REQUIRES_ROLE_KIND = {
10
+ "derived" => "generator",
11
+ "queue" => "proposer",
12
+ "quarantine" => "runner",
13
+ }.freeze
14
+ ENTRY_KEYS = %w[
9
15
  key path zone kind schema owner nested format
10
16
  compute template publish_to publish_each
11
17
  intake events inject_boot index_filename
@@ -15,8 +21,9 @@ module Textus
15
21
  RULE_KEYS = %w[match refresh intake_handler_allowlist promotion retention].freeze
16
22
  REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
17
23
  FETCH_TIMEOUT_SECONDS_CEILING = 3600
18
- PROMOTION_KEYS = %w[requires].freeze
19
- AUDIT_KEYS = %w[max_size keep].freeze
24
+ PROMOTION_KEYS = %w[requires].freeze
25
+ RETENTION_KEYS = %w[expire_after archive_after].freeze
26
+ AUDIT_KEYS = %w[max_size keep].freeze
20
27
 
21
28
  def self.validate!(raw)
22
29
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
@@ -28,11 +35,21 @@ module Textus
28
35
  validate_rules!(raw["rules"])
29
36
  walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
30
37
  validate_zone_writers_declared!(raw)
38
+ validate_single_queue!(raw)
39
+ validate_zone_kind_consistency!(raw)
31
40
  end
32
41
 
33
42
  def self.validate_zones!(zones)
34
43
  Array(zones).each_with_index do |z, i|
35
44
  walk(z, ZONE_KEYS, "$.zones[#{i}]")
45
+ if z["kind"].nil?
46
+ raise BadManifest.new("zone '#{z["name"]}' at '$.zones[#{i}]' must declare a kind (one of: #{ZONE_KINDS.join(", ")})")
47
+ end
48
+ next if ZONE_KINDS.include?(z["kind"])
49
+
50
+ raise BadManifest.new(
51
+ "unknown zone kind '#{z["kind"]}' at '$.zones[#{i}]' (known: #{ZONE_KINDS.join(", ")})",
52
+ )
36
53
  end
37
54
  end
38
55
 
@@ -54,6 +71,7 @@ module Textus
54
71
  validate_fetch_timeout!(r["refresh"]["fetch_timeout_seconds"], "#{path}.refresh.fetch_timeout_seconds")
55
72
  end
56
73
  walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
74
+ walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
57
75
  end
58
76
  end
59
77
 
@@ -115,6 +133,38 @@ module Textus
115
133
  raise BadManifest.new("unknown key '#{k}' at '#{path}'")
116
134
  end
117
135
  end
136
+
137
+ def self.validate_single_queue!(raw)
138
+ queues = Array(raw["zones"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
139
+ return if queues.size <= 1
140
+
141
+ raise BadManifest.new(
142
+ "at most one zone may declare kind: queue (found: #{queues.join(", ")})",
143
+ )
144
+ end
145
+
146
+ def self.validate_zone_kind_consistency!(raw)
147
+ mapping = role_kind_mapping(raw)
148
+ Array(raw["zones"]).each do |z|
149
+ required = KIND_REQUIRES_ROLE_KIND[z["kind"]] or next
150
+ writers = Array(z["write_policy"])
151
+ next if writers.any? { |w| mapping[w] == required }
152
+
153
+ raise BadManifest.new(
154
+ "zone '#{z["name"]}' declares kind: #{z["kind"]} but no writer is a #{required} " \
155
+ "(writers: #{writers.join(", ")})",
156
+ )
157
+ end
158
+ end
159
+
160
+ # name => kind string, honouring an explicit roles: block or the default mapping.
161
+ def self.role_kind_mapping(raw)
162
+ if raw["roles"].nil?
163
+ RoleKinds::DEFAULT_MAPPING.transform_values(&:to_s)
164
+ else
165
+ Array(raw["roles"]).to_h { |r| [r["name"], r["kind"]] }
166
+ end
167
+ end
118
168
  end
119
169
  end
120
170
  end
@@ -6,10 +6,11 @@ module Textus
6
6
  #
7
7
  # * data — frozen value: raw, root, zones, entries, audit_config, role_mapping
8
8
  # * resolver — resolves keys → entry + path
9
- # * policy — zone/role authority (zone_writers, zone_kinds, permission_for, …)
9
+ # * policy — zone/role authority (zone_writers, declared_kind/derived_zone?/
10
+ # queue_zone?, permission_for, …)
10
11
  # * rules — match-block rule engine (refresh, handler allowlist, promotion, …)
11
12
  #
12
- # Use `manifest.data.entries`, `manifest.policy.zone_kinds(z)`, etc.
13
+ # Use `manifest.data.entries`, `manifest.policy.declared_kind(z)`, etc.
13
14
  Manifest = Data.define(:data, :resolver, :policy, :rules)
14
15
  end
15
16
 
@@ -43,17 +44,12 @@ module Textus # rubocop:disable Style/OneClassPerFile
43
44
 
44
45
  def build(raw, root)
45
46
  data = Manifest::Data.parse(raw, root: root)
46
- composition = new(
47
+ new(
47
48
  data: data,
48
49
  resolver: Manifest::Resolver.new(data),
49
50
  policy: data.policy,
50
51
  rules: Manifest::Rules.parse(raw["rules"] || []),
51
52
  )
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
53
  end
58
54
 
59
55
  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
@@ -51,15 +50,7 @@ module Textus
51
50
 
52
51
  def handle_initialize(rid, _params)
53
52
  proposer = @store.manifest.policy.roles_with_kind(:proposer).first
54
- propose_zone = nil
55
- if proposer
56
- @store.manifest.data.zones.each do |zname, writers|
57
- if writers.include?(proposer) && zname.include?("review")
58
- propose_zone = zname
59
- break
60
- end
61
- end
62
- end
53
+ propose_zone = @store.manifest.policy.propose_zone_for(proposer)
63
54
 
64
55
  @session = Session.new(
65
56
  role: @role,
@@ -107,7 +98,7 @@ module Textus
107
98
  end
108
99
 
109
100
  def manifest_etag
110
- Digest::SHA256.hexdigest(File.read(File.join(@store.root, "manifest.yaml")))
101
+ @store.file_store.etag(File.join(@store.root, "manifest.yaml"))
111
102
  end
112
103
 
113
104
  def emit_result(rid, result)
@@ -1,31 +1,24 @@
1
1
  module Textus
2
2
  module MCP
3
- # Per-connection state held by the server. Immutable; advance_cursor
4
- # returns a new instance.
5
- class Session
6
- attr_reader :role, :cursor, :propose_zone, :manifest_etag
7
-
8
- def initialize(role:, cursor:, propose_zone:, manifest_etag:)
9
- @role = role
10
- @cursor = cursor
11
- @propose_zone = propose_zone
12
- @manifest_etag = manifest_etag
13
- end
14
-
15
- def advance_cursor(new_cursor)
16
- self.class.new(
17
- role: @role, cursor: new_cursor,
18
- propose_zone: @propose_zone, manifest_etag: @manifest_etag
19
- )
20
- end
3
+ # Per-connection state held by the server. Immutable Data value;
4
+ # advance_cursor returns a new instance via #with.
5
+ Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
6
+ def advance_cursor(new_cursor) = with(cursor: new_cursor)
21
7
 
22
8
  def check_etag!(observed_etag)
23
- return if observed_etag == @manifest_etag
9
+ return if observed_etag == manifest_etag
24
10
 
25
11
  raise ContractDrift.new(
26
- "manifest changed (was #{@manifest_etag[0, 8]}, now #{observed_etag[0, 8]}); re-run boot",
12
+ "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
27
13
  )
28
14
  end
15
+
16
+ private
17
+
18
+ # First 8 hex chars after the "sha256:" prefix — a stable short id for
19
+ # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
20
+ # a no-op when the prefix is absent).
21
+ def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
29
22
  end
30
23
  end
31
24
  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