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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +118 -68
- data/CHANGELOG.md +132 -0
- data/README.md +61 -19
- data/SPEC.md +107 -46
- data/docs/conventions.md +4 -4
- data/lib/textus/boot.rb +18 -12
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/verb/audit.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +1 -1
- data/lib/textus/cli/verb.rb +6 -6
- data/lib/textus/cli.rb +19 -23
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +57 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +10 -8
- data/lib/textus/doctor/check.rb +15 -5
- data/lib/textus/doctor.rb +7 -7
- data/lib/textus/domain/authorizer.rb +2 -2
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/policy/refresh.rb +1 -15
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +18 -10
- data/lib/textus/domain/staleness.rb +3 -3
- data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
- data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/event_bus.rb +8 -20
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +7 -6
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +9 -4
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +6 -6
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest/policy.rb +34 -7
- data/lib/textus/manifest/rules.rb +10 -1
- data/lib/textus/manifest/schema.rb +54 -4
- data/lib/textus/manifest.rb +4 -8
- data/lib/textus/mcp/server.rb +2 -11
- data/lib/textus/mcp/session.rb +13 -20
- data/lib/textus/mcp/tools.rb +2 -2
- data/lib/textus/mcp.rb +1 -1
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +42 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +50 -0
- data/lib/textus/schema/tools.rb +3 -3
- data/lib/textus/store.rb +16 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +40 -0
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +113 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +45 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +124 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus/write/retention_sweep.rb +55 -0
- data/lib/textus.rb +1 -2
- metadata +62 -50
- data/lib/textus/application/caps.rb +0 -49
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
- data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
- data/lib/textus/application/maintenance/migrate.rb +0 -59
- data/lib/textus/application/maintenance/rule_lint.rb +0 -65
- data/lib/textus/application/maintenance/zone_mv.rb +0 -60
- data/lib/textus/application/maintenance.rb +0 -17
- data/lib/textus/application/projection.rb +0 -93
- data/lib/textus/application/read/audit.rb +0 -106
- data/lib/textus/application/read/blame.rb +0 -91
- data/lib/textus/application/read/deps.rb +0 -34
- data/lib/textus/application/read/freshness.rb +0 -110
- data/lib/textus/application/read/get.rb +0 -75
- data/lib/textus/application/read/get_or_refresh.rb +0 -63
- data/lib/textus/application/read/list.rb +0 -25
- data/lib/textus/application/read/policy_explain.rb +0 -47
- data/lib/textus/application/read/published.rb +0 -25
- data/lib/textus/application/read/pulse.rb +0 -101
- data/lib/textus/application/read/rdeps.rb +0 -35
- data/lib/textus/application/read/schema_envelope.rb +0 -26
- data/lib/textus/application/read/stale.rb +0 -23
- data/lib/textus/application/read/uid.rb +0 -30
- data/lib/textus/application/read/validate_all.rb +0 -32
- data/lib/textus/application/read/validator.rb +0 -86
- data/lib/textus/application/read/where.rb +0 -26
- data/lib/textus/application/use_case.rb +0 -22
- data/lib/textus/application/write/accept.rb +0 -102
- data/lib/textus/application/write/authority_gate.rb +0 -26
- data/lib/textus/application/write/delete.rb +0 -45
- data/lib/textus/application/write/materializer.rb +0 -49
- data/lib/textus/application/write/mv.rb +0 -118
- data/lib/textus/application/write/publish.rb +0 -96
- data/lib/textus/application/write/put.rb +0 -49
- data/lib/textus/application/write/refresh_all.rb +0 -63
- data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
- data/lib/textus/application/write/refresh_worker.rb +0 -134
- data/lib/textus/application/write/reject.rb +0 -62
- 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, :
|
|
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(
|
|
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
|
-
|
|
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? =
|
|
28
|
-
def in_proposal_zone? =
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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::
|
|
63
|
-
pctx.emit
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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::
|
|
26
|
-
|
|
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::
|
|
33
|
-
pctx.emit
|
|
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
|
|
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
|
-
|
|
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::
|
|
53
|
-
pctx.emit
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
|
@@ -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,
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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"]
|
|
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
|
-
|
|
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
|
|
19
|
-
|
|
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
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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)
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
101
|
+
@store.file_store.etag(File.join(@store.root, "manifest.yaml"))
|
|
111
102
|
end
|
|
112
103
|
|
|
113
104
|
def emit_result(rid, result)
|
data/lib/textus/mcp/session.rb
CHANGED
|
@@ -1,31 +1,24 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
# Per-connection state held by the server. Immutable;
|
|
4
|
-
# returns a new instance.
|
|
5
|
-
|
|
6
|
-
|
|
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 ==
|
|
9
|
+
return if observed_etag == manifest_etag
|
|
24
10
|
|
|
25
11
|
raise ContractDrift.new(
|
|
26
|
-
"manifest changed (was #{
|
|
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
|
data/lib/textus/mcp/tools.rb
CHANGED
|
@@ -17,11 +17,11 @@ module Textus
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def ops_for(session, store)
|
|
20
|
-
store.
|
|
20
|
+
store.as(session.role)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
REGISTRY = {
|
|
24
|
-
"boot" => ->(_s, store, _a) {
|
|
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,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
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
|
|
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
|