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
|
@@ -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
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 =
|
|
36
|
-
@audit_log =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
|
|
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
|
data/lib/textus/version.rb
CHANGED
|
@@ -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
|