textus 0.29.0 → 0.35.1
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 +2 -235
- data/CHANGELOG.md +169 -0
- data/README.md +85 -64
- data/SPEC.md +366 -201
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +93 -76
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/hooks.rb +1 -1
- 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 +8 -8
- data/lib/textus/cli.rb +21 -18
- data/lib/textus/container.rb +1 -2
- data/lib/textus/dispatcher.rb +11 -3
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check.rb +8 -5
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +18 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope/io/reader.rb +4 -0
- data/lib/textus/envelope/io/writer.rb +8 -0
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +12 -24
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +24 -18
- data/lib/textus/maintenance/zone_mv.rb +1 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +16 -8
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/policy.rb +62 -19
- data/lib/textus/manifest/rules.rb +25 -14
- data/lib/textus/manifest/schema.rb +78 -38
- data/lib/textus/manifest.rb +6 -5
- data/lib/textus/mcp/server.rb +2 -10
- data/lib/textus/mcp/session.rb +7 -23
- data/lib/textus/mcp/tool_schemas.rb +3 -3
- data/lib/textus/mcp/tools.rb +7 -7
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +8 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/policy_explain.rb +19 -10
- data/lib/textus/read/pulse.rb +5 -4
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role_scope.rb +3 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +15 -17
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/mv.rb +17 -15
- data/lib/textus/write/put.rb +15 -17
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus/write/retention_sweep.rb +55 -0
- metadata +32 -18
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
# Resolves a manifest's `roles:` block (or the absence of one) into a
|
|
4
|
+
# capability map: { role_name => [verbs] }. Verbs are a subset of the
|
|
5
|
+
# closed capability set (Schema::CAPABILITIES). See ADR 0030.
|
|
6
|
+
module Capabilities
|
|
7
|
+
# Fallback role set for a manifest that omits `roles:` entirely. Agent
|
|
8
|
+
# is intentionally minimal here (`propose` only) — narrower than the
|
|
9
|
+
# `textus init` scaffold, which declares `agent: [propose, keep]` so the
|
|
10
|
+
# default `notebook` workspace is writable. A roles-less manifest that
|
|
11
|
+
# declares a `kind: workspace` zone is therefore rejected at load (no
|
|
12
|
+
# `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
|
|
13
|
+
DEFAULT_MAPPING = {
|
|
14
|
+
"human" => %w[author propose].freeze,
|
|
15
|
+
"agent" => %w[propose].freeze,
|
|
16
|
+
"automation" => %w[fetch build].freeze,
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
# Returns { role_name => [verbs] }. When `roles:` is declared we use
|
|
20
|
+
# exactly that; defaults are *not* layered in (declaring roles is an
|
|
21
|
+
# opt-in to a fully user-defined vocabulary).
|
|
22
|
+
def self.resolve(raw_roles)
|
|
23
|
+
return DEFAULT_MAPPING if raw_roles.nil?
|
|
24
|
+
|
|
25
|
+
raw_roles.to_h { |r| [r["name"], Array(r["can"]).freeze] }.freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/textus/manifest/data.rb
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
require_relative "schema"
|
|
2
|
-
require_relative "
|
|
2
|
+
require_relative "capabilities"
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
5
|
class Manifest
|
|
6
6
|
# Immutable, parsed view of a manifest YAML document.
|
|
7
7
|
#
|
|
8
|
-
# Holds raw structural data (zones, entries, audit_config,
|
|
8
|
+
# Holds raw structural data (zones, entries, audit_config, role_caps)
|
|
9
9
|
# but no behaviour beyond accessors. Behaviour (zone authority, key
|
|
10
10
|
# resolution, rules) lives on Manifest::Policy / Resolver / Rules.
|
|
11
11
|
class Data
|
|
12
12
|
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
13
13
|
|
|
14
|
-
attr_reader :raw, :root, :entries, :
|
|
14
|
+
attr_reader :raw, :root, :entries, :declared_zone_kinds,
|
|
15
|
+
:zone_descs, :zone_owners,
|
|
16
|
+
:audit_config, :role_caps, :policy
|
|
15
17
|
|
|
16
18
|
def self.validate_key!(key)
|
|
17
19
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
@@ -33,13 +35,19 @@ module Textus
|
|
|
33
35
|
def initialize(raw:, root:)
|
|
34
36
|
@raw = raw
|
|
35
37
|
@root = root
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
# Write authority is derived from capabilities × zone-kind (ADR 0030),
|
|
39
|
+
# not a per-zone writer list. "Which zones are declared" lives in the
|
|
40
|
+
# one kind-keyed map below (declared_zone_kinds); membership checks by
|
|
41
|
+
# read-side callers (boot, maintenance/zone_mv) use its keyset (ADR 0034).
|
|
42
|
+
@declared_zone_kinds = Array(raw["zones"]).to_h do |z|
|
|
43
|
+
[z["name"], z["kind"]&.to_sym]
|
|
40
44
|
end
|
|
45
|
+
@zone_descs = Array(raw["zones"]).to_h { |z| [z["name"], z["desc"]] }
|
|
46
|
+
# Only zones that actually declare an owner — keep nil-tombstones out so a
|
|
47
|
+
# future `zone_owners.key?(name)` means "owner declared", not "zone exists".
|
|
48
|
+
@zone_owners = Array(raw["zones"]).to_h { |z| [z["name"], z["owner"]] }.compact
|
|
41
49
|
@audit_config = build_audit_config(raw)
|
|
42
|
-
@
|
|
50
|
+
@role_caps = Capabilities.resolve(raw["roles"])
|
|
43
51
|
# Policy is constructed before entries because Entry validators
|
|
44
52
|
# call `entry.in_generator_zone?(policy)` and similar helpers
|
|
45
53
|
# that take Policy as an argument.
|
|
@@ -23,8 +23,8 @@ module Textus
|
|
|
23
23
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def in_generator_zone?(policy) = policy.
|
|
27
|
-
def in_proposal_zone?(policy) = policy.
|
|
26
|
+
def in_generator_zone?(policy) = policy.derived_zone?(@zone)
|
|
27
|
+
def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
|
|
28
28
|
|
|
29
29
|
def nested? = false
|
|
30
30
|
def derived? = false
|
|
@@ -2,46 +2,89 @@ 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). Write authority is derived from
|
|
6
|
+
# capabilities × zone-kind (ADR 0030): each zone-kind requires one verb
|
|
7
|
+
# (Schema::KIND_REQUIRES_VERB) and a role may write a zone iff its caps
|
|
8
|
+
# include that verb (verb_for_zone, roles_with_capability). Derived /
|
|
9
|
+
# proposal-queue status is authoritative via the declared-kind family
|
|
10
|
+
# (declared_kind, derived_zone?, queue_zone?, queue_zone).
|
|
6
11
|
class Policy
|
|
7
12
|
def initialize(data)
|
|
8
13
|
@data = data
|
|
9
|
-
@zone_kinds_cache = {}
|
|
10
14
|
end
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
# The capability a zone's kind requires to be written, or nil if the
|
|
17
|
+
# zone declares no kind. declared_kind returns a Symbol; the table is
|
|
18
|
+
# keyed by String.
|
|
19
|
+
def verb_for_zone(zone_name)
|
|
20
|
+
kind = declared_kind(zone_name)
|
|
21
|
+
kind && Schema::KIND_REQUIRES_VERB[kind.to_s]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Names of roles whose declared caps include `verb`.
|
|
25
|
+
def roles_with_capability(verb)
|
|
26
|
+
@data.role_caps.select { |_name, caps| caps.include?(verb) }.keys
|
|
14
27
|
end
|
|
15
28
|
|
|
16
|
-
|
|
17
|
-
|
|
29
|
+
# The conventional automated proposer: a role that can propose but is not
|
|
30
|
+
# the author-anchor (so it resolves to `agent`, not `human`, under the
|
|
31
|
+
# default mapping). Falls back to the first proposer, then nil.
|
|
32
|
+
def proposer_role
|
|
33
|
+
proposers = roles_with_capability("propose")
|
|
34
|
+
(proposers - roles_with_capability("author")).first || proposers.first
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The roles authorized to write `zone_name`: those holding the verb its
|
|
38
|
+
# kind requires. Raises on an undeclared zone.
|
|
39
|
+
def zone_writers(zone_name)
|
|
40
|
+
raise UsageError.new("undeclared zone '#{zone_name}'") unless @data.declared_zone_kinds.key?(zone_name)
|
|
41
|
+
|
|
42
|
+
roles_with_capability(verb_for_zone(zone_name))
|
|
18
43
|
end
|
|
19
44
|
|
|
20
45
|
def permission_for(zone_name)
|
|
21
46
|
Textus::Domain::Permission.new(
|
|
22
47
|
zone: zone_name,
|
|
23
|
-
|
|
24
|
-
read_policy: @data.zone_readers[zone_name] || :all,
|
|
48
|
+
writers: zone_writers(zone_name),
|
|
25
49
|
)
|
|
26
50
|
end
|
|
27
51
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
acc << k if k
|
|
32
|
-
end.freeze
|
|
52
|
+
# The kind declared on a zone in the manifest, or nil if undeclared.
|
|
53
|
+
def declared_kind(zone_name)
|
|
54
|
+
@data.declared_zone_kinds[zone_name]
|
|
33
55
|
end
|
|
34
56
|
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
# Zone names declaring `kind` (a Symbol), in manifest order. Lets callers
|
|
58
|
+
# (boot) name a kind's live zone instance(s) instead of hardcoding names.
|
|
59
|
+
def zones_of_kind(kind)
|
|
60
|
+
@data.declared_zone_kinds.select { |_name, k| k == kind }.keys
|
|
37
61
|
end
|
|
38
62
|
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
# The single zone declaring `kind: queue`, or nil. Schema guarantees <=1.
|
|
64
|
+
def queue_zone
|
|
65
|
+
@data.declared_zone_kinds.key(:queue)
|
|
41
66
|
end
|
|
42
67
|
|
|
43
|
-
|
|
44
|
-
|
|
68
|
+
# A zone is derived iff it declares kind: derived.
|
|
69
|
+
def derived_zone?(zone_name)
|
|
70
|
+
declared_kind(zone_name) == :derived
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A zone is a proposal queue iff it declares kind: queue.
|
|
74
|
+
def queue_zone?(zone_name)
|
|
75
|
+
declared_kind(zone_name) == :queue
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The zone a proposer role writes proposals into: the single zone that
|
|
79
|
+
# declares kind: queue, when the role can write it. Returns nil if there
|
|
80
|
+
# is no queue zone or the role cannot write it.
|
|
81
|
+
def propose_zone_for(role)
|
|
82
|
+
return nil if role.nil?
|
|
83
|
+
|
|
84
|
+
q = queue_zone
|
|
85
|
+
return nil unless q && zone_writers(q).include?(role)
|
|
86
|
+
|
|
87
|
+
q
|
|
45
88
|
end
|
|
46
89
|
end
|
|
47
90
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Rules
|
|
4
|
-
RuleSet = ::Data.define(:
|
|
5
|
-
EMPTY_SET = RuleSet.new(
|
|
4
|
+
RuleSet = ::Data.define(:fetch, :handler_allowlist, :guard, :retention)
|
|
5
|
+
EMPTY_SET = RuleSet.new(fetch: nil, handler_allowlist: nil, guard: nil, retention: nil)
|
|
6
6
|
|
|
7
7
|
def self.parse(raw)
|
|
8
8
|
new(Array(raw).map { |b| Block.new(b) })
|
|
@@ -15,16 +15,16 @@ module Textus
|
|
|
15
15
|
attr_reader :blocks
|
|
16
16
|
|
|
17
17
|
def for(key)
|
|
18
|
-
slots = {
|
|
18
|
+
slots = { fetch: [], handler_allowlist: [], guard: [], retention: [] }
|
|
19
19
|
@blocks.each do |b|
|
|
20
20
|
next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
|
|
21
21
|
|
|
22
22
|
slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
|
|
23
23
|
end
|
|
24
24
|
RuleSet.new(
|
|
25
|
-
|
|
25
|
+
fetch: pick(slots[:fetch], :fetch, key),
|
|
26
26
|
handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
|
|
27
|
-
|
|
27
|
+
guard: pick(slots[:guard], :guard, key),
|
|
28
28
|
retention: pick(slots[:retention], :retention, key),
|
|
29
29
|
)
|
|
30
30
|
end
|
|
@@ -44,22 +44,22 @@ module Textus
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
class Block
|
|
47
|
-
attr_reader :match, :
|
|
47
|
+
attr_reader :match, :fetch, :handler_allowlist, :guard, :retention
|
|
48
48
|
|
|
49
49
|
def initialize(raw)
|
|
50
50
|
@match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
|
|
51
|
-
@
|
|
51
|
+
@fetch = parse_fetch(raw["fetch"])
|
|
52
52
|
@handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
|
|
53
|
-
@
|
|
54
|
-
@retention = raw["retention"]
|
|
53
|
+
@guard = parse_guard(raw["guard"])
|
|
54
|
+
@retention = parse_retention(raw["retention"])
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
private
|
|
58
58
|
|
|
59
|
-
def
|
|
59
|
+
def parse_fetch(h)
|
|
60
60
|
return nil if h.nil?
|
|
61
61
|
|
|
62
|
-
Textus::Domain::Policy::
|
|
62
|
+
Textus::Domain::Policy::Fetch.new(
|
|
63
63
|
ttl: h["ttl"],
|
|
64
64
|
on_stale: h["on_stale"] || "warn",
|
|
65
65
|
sync_budget_ms: h["sync_budget_ms"],
|
|
@@ -73,12 +73,23 @@ module Textus
|
|
|
73
73
|
Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
# A guard: block is a map of transition => [predicate specs]. Predicate
|
|
77
|
+
# names are validated at GuardFactory build time via Predicates::Registry
|
|
78
|
+
# (ADR 0031); here we only assert the structural shape.
|
|
79
|
+
def parse_guard(h)
|
|
77
80
|
return nil if h.nil?
|
|
81
|
+
raise Textus::BadManifest.new("guard: must be a map of transition => [predicates]") unless h.is_a?(Hash)
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
h
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_retention(h)
|
|
87
|
+
return nil if h.nil?
|
|
80
88
|
|
|
81
|
-
Textus::Domain::Policy::
|
|
89
|
+
Textus::Domain::Policy::Retention.new(
|
|
90
|
+
expire_after: h["expire_after"],
|
|
91
|
+
archive_after: h["archive_after"],
|
|
92
|
+
)
|
|
82
93
|
end
|
|
83
94
|
end
|
|
84
95
|
end
|
|
@@ -2,21 +2,38 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
module Schema
|
|
4
4
|
ROOT_KEYS = %w[version roles zones entries rules audit].freeze
|
|
5
|
-
ROLE_KEYS = %w[name
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
ROLE_KEYS = %w[name can].freeze
|
|
6
|
+
ZONE_KEYS = %w[name kind owner desc].freeze
|
|
7
|
+
# The closed coordination vocabulary (ADR 0028; completed at five in ADR
|
|
8
|
+
# 0033; unified here in ADR 0034). Each lane pairs a zone-kind with the
|
|
9
|
+
# single capability that authorizes originating bytes in it — a total
|
|
10
|
+
# bijection. This table is the ONE source of truth; the three legacy
|
|
11
|
+
# constants below are derived from it so a zone-kind and its required
|
|
12
|
+
# capability cannot drift. Key order is canon-first so the unknown-kind
|
|
13
|
+
# error message reads canon, workspace, quarantine, queue, derived.
|
|
14
|
+
LANES = {
|
|
15
|
+
"canon" => "author",
|
|
16
|
+
"workspace" => "keep",
|
|
17
|
+
"quarantine" => "fetch",
|
|
18
|
+
"queue" => "propose",
|
|
19
|
+
"derived" => "build",
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
ZONE_KINDS = LANES.keys.freeze
|
|
23
|
+
CAPABILITIES = LANES.values.freeze
|
|
24
|
+
KIND_REQUIRES_VERB = LANES
|
|
25
|
+
ENTRY_KEYS = %w[
|
|
9
26
|
key path zone kind schema owner nested format
|
|
10
27
|
compute template publish_to publish_each
|
|
11
28
|
intake events inject_boot index_filename
|
|
12
29
|
].freeze
|
|
13
30
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
14
31
|
INTAKE_KEYS = %w[handler config].freeze
|
|
15
|
-
RULE_KEYS = %w[match
|
|
16
|
-
|
|
32
|
+
RULE_KEYS = %w[match fetch intake_handler_allowlist guard retention].freeze
|
|
33
|
+
FETCH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
|
|
17
34
|
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
18
|
-
|
|
19
|
-
AUDIT_KEYS
|
|
35
|
+
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
36
|
+
AUDIT_KEYS = %w[max_size keep].freeze
|
|
20
37
|
|
|
21
38
|
def self.validate!(raw)
|
|
22
39
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
@@ -27,12 +44,21 @@ module Textus
|
|
|
27
44
|
validate_entries!(raw["entries"])
|
|
28
45
|
validate_rules!(raw["rules"])
|
|
29
46
|
walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
|
|
30
|
-
|
|
47
|
+
validate_single_queue!(raw)
|
|
48
|
+
validate_zone_kind_consistency!(raw)
|
|
31
49
|
end
|
|
32
50
|
|
|
33
51
|
def self.validate_zones!(zones)
|
|
34
52
|
Array(zones).each_with_index do |z, i|
|
|
35
53
|
walk(z, ZONE_KEYS, "$.zones[#{i}]")
|
|
54
|
+
if z["kind"].nil?
|
|
55
|
+
raise BadManifest.new("zone '#{z["name"]}' at '$.zones[#{i}]' must declare a kind (one of: #{ZONE_KINDS.join(", ")})")
|
|
56
|
+
end
|
|
57
|
+
next if ZONE_KINDS.include?(z["kind"])
|
|
58
|
+
|
|
59
|
+
raise BadManifest.new(
|
|
60
|
+
"unknown zone kind '#{z["kind"]}' at '$.zones[#{i}]' (known: #{ZONE_KINDS.join(", ")})",
|
|
61
|
+
)
|
|
36
62
|
end
|
|
37
63
|
end
|
|
38
64
|
|
|
@@ -49,27 +75,11 @@ module Textus
|
|
|
49
75
|
Array(rules).each_with_index do |r, i|
|
|
50
76
|
path = "$.rules[#{i}]"
|
|
51
77
|
walk(r, RULE_KEYS, path)
|
|
52
|
-
if r["
|
|
53
|
-
walk(r["
|
|
54
|
-
validate_fetch_timeout!(r["
|
|
55
|
-
end
|
|
56
|
-
walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def self.validate_zone_writers_declared!(raw)
|
|
61
|
-
return if raw["roles"].nil? # default mapping is permissive
|
|
62
|
-
|
|
63
|
-
declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
|
|
64
|
-
Array(raw["zones"]).each do |z|
|
|
65
|
-
Array(z["write_policy"]).each_with_index do |w, j|
|
|
66
|
-
next if declared.include?(w)
|
|
67
|
-
|
|
68
|
-
raise BadManifest.new(
|
|
69
|
-
"zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
|
|
70
|
-
"(declared roles: #{declared.to_a.join(", ")})",
|
|
71
|
-
)
|
|
78
|
+
if r["fetch"].is_a?(Hash)
|
|
79
|
+
walk(r["fetch"], FETCH_KEYS, "#{path}.fetch")
|
|
80
|
+
validate_fetch_timeout!(r["fetch"]["fetch_timeout_seconds"], "#{path}.fetch.fetch_timeout_seconds")
|
|
72
81
|
end
|
|
82
|
+
walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
|
|
73
83
|
end
|
|
74
84
|
end
|
|
75
85
|
|
|
@@ -77,23 +87,25 @@ module Textus
|
|
|
77
87
|
return if roles.nil?
|
|
78
88
|
raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
|
|
79
89
|
|
|
80
|
-
accept_authority_count = 0
|
|
81
90
|
roles.each_with_index do |r, i|
|
|
82
91
|
path = "$.roles[#{i}]"
|
|
83
92
|
walk(r, ROLE_KEYS, path)
|
|
84
93
|
name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
|
|
88
|
-
end
|
|
94
|
+
Array(r["can"]).each do |verb|
|
|
95
|
+
next if CAPABILITIES.include?(verb)
|
|
89
96
|
|
|
90
|
-
|
|
97
|
+
raise BadManifest.new(
|
|
98
|
+
"unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
|
|
99
|
+
"(known: #{CAPABILITIES.join(", ")})",
|
|
100
|
+
)
|
|
101
|
+
end
|
|
91
102
|
end
|
|
92
|
-
|
|
103
|
+
|
|
104
|
+
author_holders = roles.count { |r| Array(r["can"]).include?("author") }
|
|
105
|
+
return if author_holders <= 1
|
|
93
106
|
|
|
94
107
|
raise BadManifest.new(
|
|
95
|
-
"manifest declares #{
|
|
96
|
-
"at most one accept_authority role is allowed",
|
|
108
|
+
"manifest declares #{author_holders} roles with the author capability; at most one is allowed",
|
|
97
109
|
)
|
|
98
110
|
end
|
|
99
111
|
|
|
@@ -115,6 +127,34 @@ module Textus
|
|
|
115
127
|
raise BadManifest.new("unknown key '#{k}' at '#{path}'")
|
|
116
128
|
end
|
|
117
129
|
end
|
|
130
|
+
|
|
131
|
+
def self.validate_single_queue!(raw)
|
|
132
|
+
queues = Array(raw["zones"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
|
|
133
|
+
return if queues.size <= 1
|
|
134
|
+
|
|
135
|
+
raise BadManifest.new(
|
|
136
|
+
"at most one zone may declare kind: queue (found: #{queues.join(", ")})",
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Write authority is derived from capabilities (ADR 0030): a zone of a
|
|
141
|
+
# given kind can only be written by a role that holds the kind's required
|
|
142
|
+
# verb. Reject a manifest declaring a zone whose required verb is held by
|
|
143
|
+
# no role. Capabilities.resolve returns the defaults when `roles:` is nil,
|
|
144
|
+
# so the capability union is all four verbs and every kind is satisfied.
|
|
145
|
+
def self.validate_zone_kind_consistency!(raw)
|
|
146
|
+
held = Capabilities.resolve(raw["roles"]).values.flatten.uniq
|
|
147
|
+
|
|
148
|
+
Array(raw["zones"]).each_with_index do |z, i|
|
|
149
|
+
verb = KIND_REQUIRES_VERB[z["kind"]]
|
|
150
|
+
next if verb.nil? || held.include?(verb)
|
|
151
|
+
|
|
152
|
+
raise BadManifest.new(
|
|
153
|
+
"zone '#{z["name"]}' (#{z["kind"]}) at '$.zones[#{i}]' " \
|
|
154
|
+
"needs a role with capability '#{verb}'; none declared",
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
118
158
|
end
|
|
119
159
|
end
|
|
120
160
|
end
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -4,12 +4,13 @@ module Textus
|
|
|
4
4
|
# Manifest is the composition record for a parsed manifest. It bundles
|
|
5
5
|
# four collaborators:
|
|
6
6
|
#
|
|
7
|
-
# * data — frozen value: raw, root, zones, entries, audit_config,
|
|
7
|
+
# * data — frozen value: raw, root, zones, entries, audit_config, role_caps
|
|
8
8
|
# * resolver — resolves keys → entry + path
|
|
9
|
-
# * policy — zone/role authority (zone_writers,
|
|
10
|
-
#
|
|
9
|
+
# * policy — zone/role authority (zone_writers, declared_kind/derived_zone?/
|
|
10
|
+
# queue_zone?, permission_for, …)
|
|
11
|
+
# * rules — match-block rule engine (fetch, 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
|
|
|
@@ -17,7 +18,7 @@ require_relative "manifest/schema"
|
|
|
17
18
|
require_relative "manifest/data"
|
|
18
19
|
require_relative "manifest/policy"
|
|
19
20
|
require_relative "manifest/resolver"
|
|
20
|
-
require_relative "manifest/
|
|
21
|
+
require_relative "manifest/capabilities"
|
|
21
22
|
|
|
22
23
|
# Reopen Textus::Manifest (defined above as a Data.define) to attach
|
|
23
24
|
# class-level loaders and helpers.
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -49,16 +49,8 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def handle_initialize(rid, _params)
|
|
52
|
-
proposer = @store.manifest.policy.
|
|
53
|
-
propose_zone =
|
|
54
|
-
if proposer
|
|
55
|
-
@store.manifest.data.zones.each do |zname, writers|
|
|
56
|
-
if writers.include?(proposer) && zname.include?("review")
|
|
57
|
-
propose_zone = zname
|
|
58
|
-
break
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
52
|
+
proposer = @store.manifest.policy.proposer_role
|
|
53
|
+
propose_zone = @store.manifest.policy.propose_zone_for(proposer)
|
|
62
54
|
|
|
63
55
|
@session = Session.new(
|
|
64
56
|
role: @role,
|
data/lib/textus/mcp/session.rb
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
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 #{short_etag(
|
|
12
|
+
"manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
|
|
27
13
|
)
|
|
28
14
|
end
|
|
29
15
|
|
|
@@ -32,9 +18,7 @@ module Textus
|
|
|
32
18
|
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
33
19
|
# the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
|
|
34
20
|
# a no-op when the prefix is absent).
|
|
35
|
-
def short_etag(etag)
|
|
36
|
-
etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
37
|
-
end
|
|
21
|
+
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
38
22
|
end
|
|
39
23
|
end
|
|
40
24
|
end
|
|
@@ -29,16 +29,16 @@ module Textus
|
|
|
29
29
|
"meta" => { "type" => "object" },
|
|
30
30
|
"body" => { "type" => "string" },
|
|
31
31
|
}, %w[key meta]),
|
|
32
|
-
tool("
|
|
32
|
+
tool("fetch", "Run an intake fetch for one key. Returns the fetch Outcome.",
|
|
33
33
|
{ "key" => { "type" => "string" } }, ["key"]),
|
|
34
|
-
tool("
|
|
34
|
+
tool("fetch_stale", "Fetch all stale intake entries, optionally scoped by zone/prefix.",
|
|
35
35
|
{
|
|
36
36
|
"zone" => { "type" => "string" },
|
|
37
37
|
"prefix" => { "type" => "string" },
|
|
38
38
|
}, []),
|
|
39
39
|
tool("schema", "Return the schema (field shape) for an entry family.",
|
|
40
40
|
{ "family" => { "type" => "string" } }, ["family"]),
|
|
41
|
-
tool("rules", "Return effective rules for a key (
|
|
41
|
+
tool("rules", "Return effective rules for a key (fetch, promote, ...).",
|
|
42
42
|
{ "key" => { "type" => "string" } }, ["key"]),
|
|
43
43
|
tool("key_mv_prefix",
|
|
44
44
|
"Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
|
data/lib/textus/mcp/tools.rb
CHANGED
|
@@ -64,14 +64,14 @@ module Textus
|
|
|
64
64
|
{ "uid" => env.uid, "etag" => env.etag, "key" => target }
|
|
65
65
|
end,
|
|
66
66
|
|
|
67
|
-
"
|
|
68
|
-
key = args.fetch("key") { raise ToolError.new("
|
|
69
|
-
outcome = ops_for(s, store).
|
|
67
|
+
"fetch" => lambda do |s, store, args|
|
|
68
|
+
key = args.fetch("key") { raise ToolError.new("fetch: missing key") }
|
|
69
|
+
outcome = ops_for(s, store).fetch(key)
|
|
70
70
|
{ "outcome" => outcome.class.name.split("::").last.downcase }
|
|
71
71
|
end,
|
|
72
72
|
|
|
73
|
-
"
|
|
74
|
-
ops_for(s, store).
|
|
73
|
+
"fetch_stale" => lambda do |s, store, args|
|
|
74
|
+
ops_for(s, store).fetch_all(zone: args["zone"], prefix: args["prefix"])
|
|
75
75
|
end,
|
|
76
76
|
|
|
77
77
|
"schema" => lambda do |_s, store, args|
|
|
@@ -83,8 +83,8 @@ module Textus
|
|
|
83
83
|
key = args.fetch("key") { raise ToolError.new("rules: missing key") }
|
|
84
84
|
set = store.manifest.rules.for(key)
|
|
85
85
|
{
|
|
86
|
-
"
|
|
87
|
-
"
|
|
86
|
+
"fetch" => set.fetch&.to_h,
|
|
87
|
+
"guard" => set.guard,
|
|
88
88
|
}.compact
|
|
89
89
|
end,
|
|
90
90
|
|
|
@@ -33,7 +33,7 @@ module Textus
|
|
|
33
33
|
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
34
34
|
extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
|
|
35
35
|
@audit_log.append(
|
|
36
|
-
role: "
|
|
36
|
+
role: "automation", verb: "event_error", key: key,
|
|
37
37
|
etag_before: nil, etag_after: nil, extras: extras
|
|
38
38
|
)
|
|
39
39
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Ports
|
|
3
|
-
module
|
|
3
|
+
module Fetch
|
|
4
4
|
module Detached
|
|
5
5
|
module_function
|
|
6
6
|
|
|
@@ -16,14 +16,14 @@ module Textus
|
|
|
16
16
|
$stdout.reopen(File::NULL, "w")
|
|
17
17
|
$stderr.reopen(File::NULL, "w")
|
|
18
18
|
|
|
19
|
-
lock = Textus::Ports::
|
|
19
|
+
lock = Textus::Ports::Fetch::Lock.new(root: store_root, key: key)
|
|
20
20
|
exit(0) unless lock.try_acquire
|
|
21
21
|
|
|
22
22
|
begin
|
|
23
23
|
store = Textus::Store.new(store_root)
|
|
24
|
-
store.as("
|
|
24
|
+
store.as("automation").fetch(key)
|
|
25
25
|
rescue StandardError
|
|
26
|
-
# Already logged via :
|
|
26
|
+
# Already logged via :fetch_failed; exit cleanly.
|
|
27
27
|
ensure
|
|
28
28
|
lock.release
|
|
29
29
|
exit(0)
|