textus 0.30.0 → 0.38.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 +2 -241
- data/CHANGELOG.md +221 -0
- data/README.md +89 -69
- data/SPEC.md +359 -212
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +122 -87
- 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/hooks.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +8 -3
- data/lib/textus/cli/verb/propose.rb +28 -0
- data/lib/textus/cli/verb/pulse.rb +12 -3
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/rule_list.rb +7 -7
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/cli.rb +2 -2
- data/lib/textus/container.rb +1 -2
- data/lib/textus/contract.rb +106 -0
- data/lib/textus/cursor_store.rb +24 -0
- data/lib/textus/dispatcher.rb +6 -4
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- 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 +15 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
- 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/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +4 -4
- data/lib/textus/init.rb +27 -18
- data/lib/textus/layout.rb +41 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
- data/lib/textus/maintenance/migrate.rb +9 -0
- data/lib/textus/maintenance/rule_lint.rb +8 -0
- data/lib/textus/maintenance/zone_mv.rb +11 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +14 -10
- data/lib/textus/manifest/policy.rb +37 -21
- data/lib/textus/manifest/rules.rb +16 -14
- data/lib/textus/manifest/schema.rb +48 -58
- data/lib/textus/manifest.rb +3 -3
- data/lib/textus/mcp/catalog.rb +72 -0
- data/lib/textus/mcp/server.rb +8 -5
- data/lib/textus/mcp/session.rb +3 -20
- data/lib/textus/mcp/tool_schemas.rb +6 -62
- data/lib/textus/mcp/tools.rb +4 -119
- data/lib/textus/ports/audit_log.rb +17 -15
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/build_lock.rb +1 -2
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/audit.rb +3 -3
- data/lib/textus/read/boot.rb +6 -0
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +16 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/list.rb +8 -0
- data/lib/textus/read/policy_explain.rb +14 -10
- data/lib/textus/read/pulse.rb +12 -4
- data/lib/textus/read/rules.rb +24 -0
- data/lib/textus/read/schema_envelope.rb +7 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role.rb +6 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/session.rb +24 -0
- data/lib/textus/store.rb +11 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +14 -2
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
- data/lib/textus/write/mv.rb +15 -3
- data/lib/textus/write/propose.rb +46 -0
- data/lib/textus/write/put.rb +26 -2
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus.rb +4 -0
- metadata +36 -21
- 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
data/lib/textus/manifest/data.rb
CHANGED
|
@@ -1,18 +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, :
|
|
15
|
-
:
|
|
14
|
+
attr_reader :raw, :root, :entries, :declared_zone_kinds,
|
|
15
|
+
:zone_descs, :zone_owners,
|
|
16
|
+
:audit_config, :role_caps, :policy
|
|
16
17
|
|
|
17
18
|
def self.validate_key!(key)
|
|
18
19
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
@@ -34,16 +35,19 @@ module Textus
|
|
|
34
35
|
def initialize(raw:, root:)
|
|
35
36
|
@raw = raw
|
|
36
37
|
@root = root
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
end
|
|
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
42
|
@declared_zone_kinds = Array(raw["zones"]).to_h do |z|
|
|
43
43
|
[z["name"], z["kind"]&.to_sym]
|
|
44
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
|
|
45
49
|
@audit_config = build_audit_config(raw)
|
|
46
|
-
@
|
|
50
|
+
@role_caps = Capabilities.resolve(raw["roles"])
|
|
47
51
|
# Policy is constructed before entries because Entry validators
|
|
48
52
|
# call `entry.in_generator_zone?(policy)` and similar helpers
|
|
49
53
|
# that take Policy as an argument.
|
|
@@ -2,28 +2,50 @@ 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, permission_for
|
|
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 /
|
|
6
9
|
# proposal-queue status is authoritative via the declared-kind family
|
|
7
|
-
# (declared_kind, derived_zone?, queue_zone?, queue_zone)
|
|
8
|
-
# from writers.
|
|
10
|
+
# (declared_kind, derived_zone?, queue_zone?, queue_zone).
|
|
9
11
|
class Policy
|
|
10
12
|
def initialize(data)
|
|
11
13
|
@data = data
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
27
|
+
end
|
|
28
|
+
|
|
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
|
|
16
35
|
end
|
|
17
36
|
|
|
18
|
-
|
|
19
|
-
|
|
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))
|
|
20
43
|
end
|
|
21
44
|
|
|
22
45
|
def permission_for(zone_name)
|
|
23
46
|
Textus::Domain::Permission.new(
|
|
24
47
|
zone: zone_name,
|
|
25
|
-
|
|
26
|
-
read_policy: @data.zone_readers[zone_name] || :all,
|
|
48
|
+
writers: zone_writers(zone_name),
|
|
27
49
|
)
|
|
28
50
|
end
|
|
29
51
|
|
|
@@ -32,6 +54,12 @@ module Textus
|
|
|
32
54
|
@data.declared_zone_kinds[zone_name]
|
|
33
55
|
end
|
|
34
56
|
|
|
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
|
|
61
|
+
end
|
|
62
|
+
|
|
35
63
|
# The single zone declaring `kind: queue`, or nil. Schema guarantees <=1.
|
|
36
64
|
def queue_zone
|
|
37
65
|
@data.declared_zone_kinds.key(:queue)
|
|
@@ -47,18 +75,6 @@ module Textus
|
|
|
47
75
|
declared_kind(zone_name) == :queue
|
|
48
76
|
end
|
|
49
77
|
|
|
50
|
-
def role_mapping
|
|
51
|
-
@data.role_mapping
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def role_kind(name)
|
|
55
|
-
@data.role_mapping[name]
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def roles_with_kind(kind)
|
|
59
|
-
@data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
78
|
# The zone a proposer role writes proposals into: the single zone that
|
|
63
79
|
# declares kind: queue, when the role can write it. Returns nil if there
|
|
64
80
|
# is no queue zone or the role cannot write it.
|
|
@@ -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
|
-
@
|
|
53
|
+
@guard = parse_guard(raw["guard"])
|
|
54
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,14 @@ 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
|
-
|
|
80
|
-
|
|
81
|
-
Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
|
|
83
|
+
h
|
|
82
84
|
end
|
|
83
85
|
|
|
84
86
|
def parse_retention(h)
|
|
@@ -2,15 +2,26 @@ 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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",
|
|
13
20
|
}.freeze
|
|
21
|
+
|
|
22
|
+
ZONE_KINDS = LANES.keys.freeze
|
|
23
|
+
CAPABILITIES = LANES.values.freeze
|
|
24
|
+
KIND_REQUIRES_VERB = LANES
|
|
14
25
|
ENTRY_KEYS = %w[
|
|
15
26
|
key path zone kind schema owner nested format
|
|
16
27
|
compute template publish_to publish_each
|
|
@@ -18,11 +29,10 @@ module Textus
|
|
|
18
29
|
].freeze
|
|
19
30
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
20
31
|
INTAKE_KEYS = %w[handler config].freeze
|
|
21
|
-
RULE_KEYS = %w[match
|
|
22
|
-
|
|
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
|
|
23
34
|
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
24
|
-
|
|
25
|
-
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
35
|
+
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
26
36
|
AUDIT_KEYS = %w[max_size keep].freeze
|
|
27
37
|
|
|
28
38
|
def self.validate!(raw)
|
|
@@ -34,7 +44,6 @@ module Textus
|
|
|
34
44
|
validate_entries!(raw["entries"])
|
|
35
45
|
validate_rules!(raw["rules"])
|
|
36
46
|
walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
|
|
37
|
-
validate_zone_writers_declared!(raw)
|
|
38
47
|
validate_single_queue!(raw)
|
|
39
48
|
validate_zone_kind_consistency!(raw)
|
|
40
49
|
end
|
|
@@ -66,52 +75,37 @@ module Textus
|
|
|
66
75
|
Array(rules).each_with_index do |r, i|
|
|
67
76
|
path = "$.rules[#{i}]"
|
|
68
77
|
walk(r, RULE_KEYS, path)
|
|
69
|
-
if r["
|
|
70
|
-
walk(r["
|
|
71
|
-
validate_fetch_timeout!(r["
|
|
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
|
|
73
|
-
walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
|
|
74
82
|
walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
|
|
75
83
|
end
|
|
76
84
|
end
|
|
77
85
|
|
|
78
|
-
def self.validate_zone_writers_declared!(raw)
|
|
79
|
-
return if raw["roles"].nil? # default mapping is permissive
|
|
80
|
-
|
|
81
|
-
declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
|
|
82
|
-
Array(raw["zones"]).each do |z|
|
|
83
|
-
Array(z["write_policy"]).each_with_index do |w, j|
|
|
84
|
-
next if declared.include?(w)
|
|
85
|
-
|
|
86
|
-
raise BadManifest.new(
|
|
87
|
-
"zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
|
|
88
|
-
"(declared roles: #{declared.to_a.join(", ")})",
|
|
89
|
-
)
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
86
|
def self.validate_roles!(roles)
|
|
95
87
|
return if roles.nil?
|
|
96
88
|
raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
|
|
97
89
|
|
|
98
|
-
accept_authority_count = 0
|
|
99
90
|
roles.each_with_index do |r, i|
|
|
100
91
|
path = "$.roles[#{i}]"
|
|
101
92
|
walk(r, ROLE_KEYS, path)
|
|
102
93
|
name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
|
|
106
|
-
end
|
|
94
|
+
Array(r["can"]).each do |verb|
|
|
95
|
+
next if CAPABILITIES.include?(verb)
|
|
107
96
|
|
|
108
|
-
|
|
97
|
+
raise BadManifest.new(
|
|
98
|
+
"unknown capability '#{verb}' for role '#{name}' at '#{path}' " \
|
|
99
|
+
"(known: #{CAPABILITIES.join(", ")})",
|
|
100
|
+
)
|
|
101
|
+
end
|
|
109
102
|
end
|
|
110
|
-
|
|
103
|
+
|
|
104
|
+
author_holders = roles.count { |r| Array(r["can"]).include?("author") }
|
|
105
|
+
return if author_holders <= 1
|
|
111
106
|
|
|
112
107
|
raise BadManifest.new(
|
|
113
|
-
"manifest declares #{
|
|
114
|
-
"at most one accept_authority role is allowed",
|
|
108
|
+
"manifest declares #{author_holders} roles with the author capability; at most one is allowed",
|
|
115
109
|
)
|
|
116
110
|
end
|
|
117
111
|
|
|
@@ -143,28 +137,24 @@ module Textus
|
|
|
143
137
|
)
|
|
144
138
|
end
|
|
145
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.
|
|
146
145
|
def self.validate_zone_kind_consistency!(raw)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
next if
|
|
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)
|
|
152
151
|
|
|
153
152
|
raise BadManifest.new(
|
|
154
|
-
"zone '#{z["name"]}'
|
|
155
|
-
"
|
|
153
|
+
"zone '#{z["name"]}' (#{z["kind"]}) at '$.zones[#{i}]' " \
|
|
154
|
+
"needs a role with capability '#{verb}'; none declared",
|
|
156
155
|
)
|
|
157
156
|
end
|
|
158
157
|
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
|
|
168
158
|
end
|
|
169
159
|
end
|
|
170
160
|
end
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -4,11 +4,11 @@ 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
9
|
# * policy — zone/role authority (zone_writers, declared_kind/derived_zone?/
|
|
10
10
|
# queue_zone?, permission_for, …)
|
|
11
|
-
# * rules — match-block rule engine (
|
|
11
|
+
# * rules — match-block rule engine (fetch, handler allowlist, promotion, …)
|
|
12
12
|
#
|
|
13
13
|
# Use `manifest.data.entries`, `manifest.policy.declared_kind(z)`, etc.
|
|
14
14
|
Manifest = Data.define(:data, :resolver, :policy, :rules)
|
|
@@ -18,7 +18,7 @@ require_relative "manifest/schema"
|
|
|
18
18
|
require_relative "manifest/data"
|
|
19
19
|
require_relative "manifest/policy"
|
|
20
20
|
require_relative "manifest/resolver"
|
|
21
|
-
require_relative "manifest/
|
|
21
|
+
require_relative "manifest/capabilities"
|
|
22
22
|
|
|
23
23
|
# Reopen Textus::Manifest (defined above as a Data.define) to attach
|
|
24
24
|
# class-level loaders and helpers.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module MCP
|
|
3
|
+
# Derives the entire MCP tool surface from the per-verb contracts
|
|
4
|
+
# (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
|
|
5
|
+
# tools/call dispatch: map JSON args -> (positional, keyword) per the
|
|
6
|
+
# contract, invoke the verb through the role scope, then shape the
|
|
7
|
+
# return value with the contract's response block. No per-tool code.
|
|
8
|
+
module Catalog
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Contracts of every MCP-surfaced verb, in Dispatcher order.
|
|
12
|
+
def specs
|
|
13
|
+
Textus::Dispatcher::VERBS.values
|
|
14
|
+
.select { |k| k.respond_to?(:contract?) && k.contract? && k.contract.mcp? }
|
|
15
|
+
.map(&:contract)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tool_schemas
|
|
19
|
+
specs.map do |s|
|
|
20
|
+
{ name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
|
|
21
|
+
end.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def names
|
|
25
|
+
specs.map { |s| s.verb.to_s }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(name, session:, store:, args:)
|
|
29
|
+
klass = Textus::Dispatcher::VERBS[name.to_sym]
|
|
30
|
+
raise ToolError.new("unknown tool: #{name}") unless klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
31
|
+
|
|
32
|
+
spec = klass.contract
|
|
33
|
+
pos, kw = map_args(spec, args || {}, session)
|
|
34
|
+
result = store.as(session.role).public_send(spec.verb, *pos, **kw)
|
|
35
|
+
spec.response.call(result)
|
|
36
|
+
rescue ContractDrift, CursorExpired
|
|
37
|
+
raise
|
|
38
|
+
rescue Textus::Error => e
|
|
39
|
+
raise ToolError.new("#{name}: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Splits the raw JSON arg hash into the positional list and keyword hash
|
|
43
|
+
# the use-case expects, validating required presence first.
|
|
44
|
+
# Session-default args (session_default: :method_name) are injected from
|
|
45
|
+
# the session when absent from the wire; they are never treated as missing.
|
|
46
|
+
# Positional args are emitted in contract declaration order; use-case signatures must match.
|
|
47
|
+
def map_args(spec, raw, session = nil)
|
|
48
|
+
missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
|
|
49
|
+
raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
|
|
50
|
+
|
|
51
|
+
positional = []
|
|
52
|
+
keyword = {}
|
|
53
|
+
spec.args.each do |a|
|
|
54
|
+
if raw.key?(a.name.to_s)
|
|
55
|
+
value = raw[a.name.to_s]
|
|
56
|
+
elsif a.session_default && session
|
|
57
|
+
value = session.public_send(a.session_default)
|
|
58
|
+
else
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if a.positional
|
|
63
|
+
positional << value
|
|
64
|
+
else
|
|
65
|
+
keyword[a.name] = value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
[positional, keyword]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -49,8 +49,11 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def handle_initialize(rid, _params)
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
# The acting role IS the resolved connection role (ADR 0040): the MCP
|
|
53
|
+
# transport defaults to `agent`, which can write the queue, so its
|
|
54
|
+
# propose_zone resolves directly. If a connection's role cannot propose,
|
|
55
|
+
# propose_zone is nil and the `propose` tool reports that honestly.
|
|
56
|
+
propose_zone = @store.manifest.policy.propose_zone_for(@role)
|
|
54
57
|
|
|
55
58
|
@session = Session.new(
|
|
56
59
|
role: @role,
|
|
@@ -67,7 +70,7 @@ module Textus
|
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
def handle_tools_list(rid)
|
|
70
|
-
emit_result(rid, { "tools" =>
|
|
73
|
+
emit_result(rid, { "tools" => Catalog.tool_schemas })
|
|
71
74
|
end
|
|
72
75
|
|
|
73
76
|
def handle_tools_call(rid, params)
|
|
@@ -80,8 +83,8 @@ module Textus
|
|
|
80
83
|
|
|
81
84
|
name = params["name"]
|
|
82
85
|
args = params["arguments"] || {}
|
|
83
|
-
result =
|
|
84
|
-
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "
|
|
86
|
+
result = Catalog.call(name, session: @session, store: @store, args: args)
|
|
87
|
+
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
|
|
85
88
|
|
|
86
89
|
emit_result(rid, {
|
|
87
90
|
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
data/lib/textus/mcp/session.rb
CHANGED
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
Session =
|
|
6
|
-
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
7
|
-
|
|
8
|
-
def check_etag!(observed_etag)
|
|
9
|
-
return if observed_etag == manifest_etag
|
|
10
|
-
|
|
11
|
-
raise ContractDrift.new(
|
|
12
|
-
"manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
|
|
13
|
-
)
|
|
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]
|
|
22
|
-
end
|
|
3
|
+
# The session value now lives in core (ADR 0036); retained here as an
|
|
4
|
+
# alias so existing MCP references keep resolving.
|
|
5
|
+
Session = Textus::Session
|
|
23
6
|
end
|
|
24
7
|
end
|
|
@@ -1,70 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
|
|
4
|
+
# per-verb contracts; this delegates to MCP::Catalog. The hand-written
|
|
5
|
+
# array is gone — a kwarg rename now updates the schema automatically (and
|
|
6
|
+
# the signature guard fails if the contract lags the use-case).
|
|
6
7
|
module ToolSchemas
|
|
7
8
|
module_function
|
|
8
9
|
|
|
9
|
-
def all
|
|
10
|
-
|
|
11
|
-
tool("boot", "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart.", {}, []),
|
|
12
|
-
tool("tick", "Delta since cursor. Returns {cursor, changed, stale, pending_review, doctor}.",
|
|
13
|
-
{ "since" => { "type" => "integer", "minimum" => 0 } }, []),
|
|
14
|
-
tool("find", "List keys filtered by zone and/or prefix.",
|
|
15
|
-
{ "zone" => { "type" => "string" }, "prefix" => { "type" => "string" } }, []),
|
|
16
|
-
tool("read", "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness).",
|
|
17
|
-
{ "key" => { "type" => "string" } }, ["key"]),
|
|
18
|
-
tool("write", "Create or update an entry. Schema-validated. Returns {uid, etag}.",
|
|
19
|
-
{
|
|
20
|
-
"key" => { "type" => "string" },
|
|
21
|
-
"meta" => { "type" => "object" },
|
|
22
|
-
"body" => { "type" => "string" },
|
|
23
|
-
"content" => { "type" => "object" },
|
|
24
|
-
"if_etag" => { "type" => "string" },
|
|
25
|
-
}, %w[key meta]),
|
|
26
|
-
tool("propose", "Write a proposal to the session's propose_zone. Auto-prefixes the key.",
|
|
27
|
-
{
|
|
28
|
-
"key" => { "type" => "string", "description" => "Key relative to propose_zone, e.g. 'proposal.feature-x'" },
|
|
29
|
-
"meta" => { "type" => "object" },
|
|
30
|
-
"body" => { "type" => "string" },
|
|
31
|
-
}, %w[key meta]),
|
|
32
|
-
tool("refresh", "Run an intake refresh for one key. Returns the refresh Outcome.",
|
|
33
|
-
{ "key" => { "type" => "string" } }, ["key"]),
|
|
34
|
-
tool("refresh_stale", "Refresh all stale intake entries, optionally scoped by zone/prefix.",
|
|
35
|
-
{
|
|
36
|
-
"zone" => { "type" => "string" },
|
|
37
|
-
"prefix" => { "type" => "string" },
|
|
38
|
-
}, []),
|
|
39
|
-
tool("schema", "Return the schema (field shape) for an entry family.",
|
|
40
|
-
{ "family" => { "type" => "string" } }, ["family"]),
|
|
41
|
-
tool("rules", "Return effective rules for a key (refresh, promote, ...).",
|
|
42
|
-
{ "key" => { "type" => "string" } }, ["key"]),
|
|
43
|
-
tool("key_mv_prefix",
|
|
44
|
-
"Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
|
|
45
|
-
{ "from_prefix" => { "type" => "string" }, "to_prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
46
|
-
%w[from_prefix to_prefix]),
|
|
47
|
-
tool("key_delete_prefix", "Bulk-delete every leaf key under prefix.",
|
|
48
|
-
{ "prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
49
|
-
["prefix"]),
|
|
50
|
-
tool("zone_mv", "Rename a zone — manifest + files. Refuses if destination exists.",
|
|
51
|
-
{ "from" => { "type" => "string" }, "to" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
52
|
-
%w[from to]),
|
|
53
|
-
tool("rule_lint", "Diff candidate manifest YAML's rules against the live manifest. No writes.",
|
|
54
|
-
{ "candidate_yaml" => { "type" => "string" } },
|
|
55
|
-
["candidate_yaml"]),
|
|
56
|
-
tool("migrate", "Run a YAML migration plan (multi-op).",
|
|
57
|
-
{ "plan_yaml" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
58
|
-
["plan_yaml"]),
|
|
59
|
-
].freeze
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def tool(name, description, properties, required)
|
|
63
|
-
{
|
|
64
|
-
name: name,
|
|
65
|
-
description: description,
|
|
66
|
-
inputSchema: { type: "object", properties: properties, required: required },
|
|
67
|
-
}
|
|
10
|
+
def all
|
|
11
|
+
Catalog.tool_schemas
|
|
68
12
|
end
|
|
69
13
|
end
|
|
70
14
|
end
|