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
@@ -0,0 +1,69 @@
1
+ module Textus
2
+ module Read
3
+ # Composes pure `Read::Get` with the refresh orchestrator: runs Get
4
+ # to obtain the envelope and freshness verdict, then if the verdict
5
+ # is stale and the rule's `on_stale` policy demands action, hands
6
+ # off to the orchestrator. Use for interactive reads where the
7
+ # caller wants the freshest obtainable envelope.
8
+ #
9
+ # Pure reads (build, projection, schema tooling) should use
10
+ # `Read::Get` directly; it has no orchestrator dependency.
11
+ class GetOrRefresh
12
+ def initialize(container:, call:, get: nil, orchestrator: nil)
13
+ @container = container
14
+ @call = call
15
+ @manifest = container.manifest
16
+ @get = get || Read::Get.new(container: container, call: call)
17
+ @orchestrator = orchestrator || build_orchestrator
18
+ end
19
+
20
+ private
21
+
22
+ def hook_context
23
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
24
+ end
25
+
26
+ def build_orchestrator
27
+ worker = Textus::Write::RefreshWorker.new(
28
+ container: @container, call: @call,
29
+ )
30
+ Textus::Write::RefreshOrchestrator.new(
31
+ worker: worker, store_root: @container.root, events: @container.events,
32
+ hook_context: hook_context
33
+ )
34
+ end
35
+
36
+ public
37
+
38
+ def call(key)
39
+ envelope = @get.call(key)
40
+ return nil if envelope.nil?
41
+ return envelope unless envelope.freshness&.stale
42
+
43
+ policy_set = @manifest.rules.for(key)
44
+ refresh_policy = policy_set.refresh
45
+ return envelope if refresh_policy.nil?
46
+
47
+ policy = refresh_policy.to_freshness_policy
48
+ verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
49
+ action = policy.decide(verdict)
50
+ outcome = @orchestrator.execute(action, key: key)
51
+
52
+ case outcome
53
+ when Textus::Domain::Outcome::Skipped
54
+ envelope
55
+ when Textus::Domain::Outcome::Refreshed
56
+ outcome.envelope.with(
57
+ freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
58
+ )
59
+ when Textus::Domain::Outcome::Detached
60
+ envelope.with(freshness: envelope.freshness.with(refreshing: true))
61
+ when Textus::Domain::Outcome::Failed
62
+ envelope.with(
63
+ freshness: envelope.freshness.with(refresh_error: outcome.error.message),
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Read
3
+ class List
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ end
7
+
8
+ def call(prefix: nil, zone: nil)
9
+ rows = @manifest.resolver.enumerate(prefix: prefix)
10
+ rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
11
+ rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Read
3
+ # For one key, surface every matching policy block along with the
4
+ # per-slot effective value (which loses ties win-by-specificity).
5
+ class PolicyExplain
6
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
7
+ @manifest = container.manifest
8
+ end
9
+
10
+ def call(key:)
11
+ policies = @manifest.rules
12
+ matching = policies.explain(key)
13
+ winners = policies.for(key)
14
+
15
+ {
16
+ key: key,
17
+ matched_blocks: matching.map do |b|
18
+ {
19
+ match: b.match,
20
+ refresh: !b.refresh.nil?,
21
+ handler_allowlist: !b.handler_allowlist.nil?,
22
+ promote: !b.promote.nil?,
23
+ retention: !b.retention.nil?,
24
+ }
25
+ end,
26
+ effective: {
27
+ refresh: winners.refresh && {
28
+ ttl_seconds: winners.refresh.ttl_seconds,
29
+ on_stale: winners.refresh.on_stale,
30
+ },
31
+ handler_allowlist: winners.handler_allowlist&.handlers,
32
+ promotion: winners.promote && { requires: winners.promote.requires },
33
+ retention: winners.retention && {
34
+ expire_after: winners.retention.expire_after,
35
+ archive_after: winners.retention.archive_after,
36
+ },
37
+ },
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Read
3
+ class Published
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ end
7
+
8
+ def call
9
+ @manifest.data.entries.reject { |e| e.publish_to.empty? }.map do |e|
10
+ { "key" => e.key, "publish_to" => e.publish_to }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,89 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ module Read
5
+ # Aggregator over audit + freshness + review + doctor. One round-trip
6
+ # for an agent's per-turn heartbeat. All component reads are existing
7
+ # APIs; pulse is sugar with a stable envelope shape and a monotonic
8
+ # cursor (seq).
9
+ class Pulse
10
+ def initialize(container:, call:)
11
+ @container = container
12
+ @call = call
13
+ @manifest = container.manifest
14
+ @file_store = container.file_store
15
+ @audit_log = container.audit_log
16
+ @root = container.root
17
+ @events = container.events
18
+ end
19
+
20
+ def call(since: 0)
21
+ freshness_rows = freshness.call
22
+ {
23
+ "cursor" => @audit_log.latest_seq,
24
+ "changed" => audit_changes_since(since),
25
+ "stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
26
+ "pending_review" => review_keys,
27
+ "doctor" => doctor_summary,
28
+ "manifest_etag" => manifest_etag,
29
+ "next_due_at" => soonest_due(freshness_rows),
30
+ "hook_errors" => hook_errors_since(since),
31
+ }
32
+ end
33
+
34
+ private
35
+
36
+ def audit_changes_since(seq)
37
+ Read::Audit.new(container: @container).call(seq_since: seq)
38
+ end
39
+
40
+ def freshness
41
+ @freshness ||= Read::Freshness.new(container: @container, call: @call)
42
+ end
43
+
44
+ def soonest_due(rows)
45
+ times = rows.map { |r| r[:next_due_at] }.compact.map { |t| Time.parse(t) }
46
+ return nil if times.empty?
47
+
48
+ times.min.utc.iso8601
49
+ end
50
+
51
+ def review_keys
52
+ # List constructor takes only manifest:; returns hashes with string keys.
53
+ # Guard: zones is a Hash keyed by name string.
54
+ return [] unless @manifest.data.zones.key?("review")
55
+
56
+ rows = Read::List.new(container: @container).call(zone: "review")
57
+ rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
58
+ end
59
+
60
+ def doctor_summary
61
+ result = Textus::Doctor.build(container: @container)
62
+ issues = result["issues"] || []
63
+ {
64
+ "ok" => result["ok"],
65
+ "warn" => issues.count { |i| i["level"] == "warning" },
66
+ "fail" => issues.count { |i| i["level"] == "error" },
67
+ }
68
+ end
69
+
70
+ def manifest_etag
71
+ @file_store.etag(File.join(@root, "manifest.yaml"))
72
+ end
73
+
74
+ def hook_errors_since(seq)
75
+ @events.error_log.since(seq).map do |r|
76
+ {
77
+ "seq" => r[:seq],
78
+ "event" => r[:event].to_s,
79
+ "hook" => r[:hook].to_s,
80
+ "key" => r[:key],
81
+ "error_class" => r[:error_class],
82
+ "error_message" => r[:error_message],
83
+ "at" => r[:at],
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Read
3
+ class Rdeps
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ end
7
+
8
+ def call(key)
9
+ @manifest.data.entries.each_with_object([]) do |e, acc|
10
+ next unless e.is_a?(Textus::Manifest::Entry::Derived)
11
+
12
+ src = e.source
13
+ sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
14
+ Array(src.select).compact
15
+ elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
16
+ Array(src.sources).compact
17
+ else
18
+ []
19
+ end
20
+ acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Read
3
+ class Retainable
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ end
7
+
8
+ def call(prefix: nil, zone: nil)
9
+ Textus::Domain::Retention.new(
10
+ manifest: @manifest,
11
+ file_stat: Textus::Ports::Storage::FileStat.new,
12
+ clock: Textus::Ports::Clock,
13
+ ).call(prefix: prefix, zone: zone)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ module Read
3
+ class SchemaEnvelope
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ @schemas = container.schemas
7
+ end
8
+
9
+ def call(key)
10
+ mentry = @manifest.resolver.resolve(key).entry
11
+ schema = @schemas.fetch_or_nil(mentry.schema)
12
+ { "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Read
3
+ class Stale
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ end
7
+
8
+ def call(prefix: nil, zone: nil)
9
+ Textus::Domain::Staleness.new(
10
+ manifest: @manifest,
11
+ file_stat: Textus::Ports::Storage::FileStat.new,
12
+ clock: Textus::Ports::Clock,
13
+ ).call(prefix: prefix, zone: zone)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ module Read
3
+ class Uid
4
+ def initialize(container:, call:)
5
+ @container = container
6
+ @call = call
7
+ end
8
+
9
+ def call(key)
10
+ get.get(key).uid
11
+ end
12
+
13
+ private
14
+
15
+ def get
16
+ @get ||= Get.new(container: @container, call: @call)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ module Read
3
+ class ValidateAll
4
+ def initialize(container:, call:)
5
+ @container = container
6
+ @call = call
7
+ @manifest = container.manifest
8
+ @schemas = container.schemas
9
+ @audit_log = container.audit_log
10
+ end
11
+
12
+ def call
13
+ Validator.new(
14
+ reader: Get.new(container: @container, call: @call),
15
+ manifest: @manifest,
16
+ audit_log: @audit_log,
17
+ schema_for: ->(name) { @schemas.fetch_or_nil(name) },
18
+ ).call
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,84 @@
1
+ module Textus
2
+ module Read
3
+ class Validator
4
+ def initialize(reader:, manifest:, audit_log:, schema_for:)
5
+ @reader = reader
6
+ @manifest = manifest
7
+ @audit_log = audit_log
8
+ @schema_for = schema_for
9
+ end
10
+
11
+ def call
12
+ violations = []
13
+ check_content_violations(violations)
14
+ check_role_authority_violations(violations)
15
+ { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
16
+ end
17
+
18
+ private
19
+
20
+ def check_content_violations(violations)
21
+ @manifest.resolver.enumerate.each do |row|
22
+ key = row[:key]
23
+ mentry = row[:manifest_entry]
24
+ env = fetch_envelope(key, violations) or next
25
+ schema = mentry.schema && @schema_for.call(mentry.schema)
26
+ next unless schema
27
+
28
+ begin
29
+ validate_schema!(schema, env, mentry.format)
30
+ rescue Textus::Error => e
31
+ violations << { "key" => key, "code" => e.code, "message" => e.message }
32
+ end
33
+ end
34
+ end
35
+
36
+ def check_role_authority_violations(violations)
37
+ @manifest.resolver.enumerate.each do |row|
38
+ mentry = row[:manifest_entry]
39
+ next unless mentry.schema
40
+
41
+ schema = @schema_for.call(mentry.schema)
42
+ next unless schema
43
+
44
+ env = begin
45
+ @reader.get(row[:key])
46
+ rescue StandardError
47
+ next
48
+ end
49
+ append_authority_violations(violations, row[:key], env, schema)
50
+ end
51
+ end
52
+
53
+ def append_authority_violations(violations, key, env, schema)
54
+ last_writer = @audit_log.last_writer_for(key)
55
+ return if last_writer.nil?
56
+
57
+ last_writer_is_authority = @manifest.policy.role_kind(last_writer) == :accept_authority
58
+
59
+ env.meta.each_key do |field|
60
+ owner = schema.maintained_by(field)
61
+ next if owner.nil? || last_writer == owner || last_writer_is_authority
62
+
63
+ violations << { "key" => key, "code" => "role_authority",
64
+ "field" => field, "expected" => owner, "last_writer" => last_writer }
65
+ end
66
+ end
67
+
68
+ def fetch_envelope(key, violations)
69
+ @reader.get(key)
70
+ rescue Textus::Error => e
71
+ violations << { "key" => key, "code" => e.code, "message" => e.message }
72
+ nil
73
+ end
74
+
75
+ def validate_schema!(schema, envelope, format)
76
+ payload = case format
77
+ when "json", "yaml" then envelope.content || {}
78
+ else envelope.meta || {}
79
+ end
80
+ schema.validate!(payload)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ module Read
3
+ class Where
4
+ def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
+ @manifest = container.manifest
6
+ end
7
+
8
+ def call(key)
9
+ res = @manifest.resolver.resolve(key)
10
+ mentry = res.entry
11
+ path = res.path
12
+ { "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ # Thin role-scoped facade over a Container. Closes over a role default
3
+ # and a dry_run flag, then forwards every verb in Dispatcher::VERBS to
4
+ # the corresponding use case.
5
+ #
6
+ # Replaces the per-call Session under the 0.27.0 architecture: a Store
7
+ # exposes #as(role) to get a RoleScope, and Store#put / Store#get / etc
8
+ # delegate to RoleScope under the default role.
9
+ class RoleScope
10
+ attr_reader :container, :role, :correlation_id
11
+
12
+ def dry_run?
13
+ @dry_run
14
+ end
15
+
16
+ def initialize(container:, role:, dry_run: false, correlation_id: nil)
17
+ @container = container
18
+ @role = role.to_s
19
+ @dry_run = dry_run
20
+ @correlation_id = correlation_id
21
+ end
22
+
23
+ def with_role(role)
24
+ self.class.new(container: @container, role: role, dry_run: @dry_run, correlation_id: @correlation_id)
25
+ end
26
+
27
+ def with_correlation_id(cid)
28
+ self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
29
+ end
30
+
31
+ def hook_context
32
+ @hook_context ||= Textus::Hooks::Context.new(scope: self)
33
+ end
34
+
35
+ def with_dry_run
36
+ self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
37
+ end
38
+
39
+ Textus::Dispatcher::VERBS.each_key do |verb|
40
+ define_method(verb) do |*args, **kwargs|
41
+ call_value = Textus::Call.build(
42
+ role: @role, correlation_id: @correlation_id, dry_run: @dry_run,
43
+ )
44
+ Textus::Dispatcher.invoke(
45
+ verb, container: @container, call: call_value, args: args, kwargs: kwargs
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
@@ -6,7 +6,7 @@ module Textus
6
6
  module Tools
7
7
  # textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
8
8
  def self.init(store, name:, from:)
9
- env = store.session.get(from)
9
+ env = store.as(Textus::Role::DEFAULT).get(from)
10
10
  meta = env.meta
11
11
  schema = {
12
12
  "name" => name,
@@ -25,7 +25,7 @@ module Textus
25
25
  schema = load_schema(store, name)
26
26
  drift = []
27
27
  store.manifest.resolver.enumerate.each do |row|
28
- env = store.session.get(row[:key])
28
+ env = store.as(Textus::Role::DEFAULT).get(row[:key])
29
29
  begin
30
30
  schema.validate!(env.meta)
31
31
  rescue SchemaViolation => e
@@ -50,7 +50,7 @@ module Textus
50
50
  raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
51
51
 
52
52
  authority = accept_authority_for(store)
53
- ops = store.session(role: authority)
53
+ ops = store.as(authority)
54
54
  touched = []
55
55
  store.manifest.resolver.enumerate.each do |row|
56
56
  env = ops.get(row[:key])
data/lib/textus/store.rb CHANGED
@@ -32,23 +32,32 @@ module Textus
32
32
  @root = File.expand_path(root)
33
33
  @manifest = Manifest.load(@root)
34
34
  @schemas = Schemas.new(File.join(@root, "schemas"))
35
- @file_store = Infra::Storage::FileStore.new
36
- @audit_log = Infra::AuditLog.new(
35
+ @file_store = Ports::Storage::FileStore.new
36
+ @audit_log = Ports::AuditLog.new(
37
37
  @root,
38
38
  max_size: @manifest.data.audit_config[:max_size],
39
39
  keep: @manifest.data.audit_config[:keep],
40
40
  )
41
41
  @events = Hooks::EventBus.new
42
42
  @rpc = Hooks::RpcRegistry.new
43
- Infra::AuditSubscriber.new(@audit_log).attach(@events)
43
+ Ports::AuditSubscriber.new(@audit_log).attach(@events)
44
44
  Hooks::Builtin.register_all(events: @events, rpc: @rpc)
45
45
  Hooks::Loader.new(events: @events, rpc: @rpc).load_dir(File.join(@root, "hooks"))
46
- sess = Session.for(self, role: Role::DEFAULT)
47
- @events.publish(:store_loaded, ctx: sess.hook_context)
46
+ @events.publish(:store_loaded, ctx: Hooks::Context.new(scope: as(Role::DEFAULT)))
48
47
  end
49
48
 
50
- def session(role: Role::DEFAULT, correlation_id: nil, dry_run: false)
51
- Session.for(self, role: role, correlation_id: correlation_id, dry_run: dry_run)
49
+ def container
50
+ @container ||= Textus::Container.from_store(self)
51
+ end
52
+
53
+ def as(role, dry_run: false, correlation_id: nil)
54
+ RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
55
+ end
56
+
57
+ Textus::Dispatcher::VERBS.each_key do |verb|
58
+ define_method(verb) do |*args, role: Role::DEFAULT, **kwargs|
59
+ as(role).public_send(verb, *args, **kwargs)
60
+ end
52
61
  end
53
62
  end
54
63
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.26.0"
2
+ VERSION = "0.30.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -0,0 +1,86 @@
1
+ require_relative "authority_gate"
2
+
3
+ module Textus
4
+ module Write
5
+ class Accept
6
+ include AuthorityGate
7
+
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ @manifest = container.manifest
12
+ @schemas = container.schemas
13
+ @events = container.events
14
+ end
15
+
16
+ def call(pending_key)
17
+ assert_accept_authority!("accept")
18
+
19
+ env = Textus::Read::Get.new(
20
+ container: @container, call: @call,
21
+ ).call(pending_key)
22
+ proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
23
+ target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
24
+ action = proposal["action"] || "put"
25
+
26
+ evaluate_promotion!(env, target)
27
+
28
+ case action
29
+ when "put"
30
+ # Nested proposal "frontmatter" — the meta to write to the accepted
31
+ # target. Not related to the removed intake-handler legacy bridge.
32
+ target_meta = env.meta["frontmatter"] || {}
33
+ target_body = env.body
34
+ put_op.call(target, meta: target_meta, body: target_body)
35
+ when "delete"
36
+ delete_op.call(target)
37
+ else
38
+ raise ProposalError.new("unknown action: #{action}")
39
+ end
40
+
41
+ delete_op.call(pending_key)
42
+
43
+ @events.publish(:proposal_accepted,
44
+ ctx: hook_context,
45
+ key: pending_key,
46
+ target_key: target)
47
+
48
+ { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
49
+ end
50
+
51
+ private
52
+
53
+ def hook_context
54
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
55
+ end
56
+
57
+ def put_op
58
+ @put_op ||= Textus::Write::Put.new(
59
+ container: @container, call: @call,
60
+ )
61
+ end
62
+
63
+ def delete_op
64
+ @delete_op ||= Textus::Write::Delete.new(
65
+ container: @container, call: @call,
66
+ )
67
+ end
68
+
69
+ def evaluate_promotion!(env, target_key)
70
+ rules = @manifest.rules.for(target_key)
71
+ promote = rules.promote
72
+ return if promote.nil? || promote.requires.empty?
73
+
74
+ policy = Textus::Domain::Policy::Promotion.from_names(promote.requires)
75
+ result = policy.evaluate(
76
+ entry: env, schemas: @schemas, manifest: @manifest, role: @call.role,
77
+ )
78
+ return if result.ok?
79
+
80
+ raise ProposalError.new(
81
+ "promotion gate failed: #{result.reasons.join("; ")}",
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end