textus 0.30.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 -241
- data/CHANGELOG.md +113 -0
- data/README.md +83 -62
- data/SPEC.md +352 -211
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +89 -74
- 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/put.rb +1 -1
- data/lib/textus/cli/verb/rule_list.rb +7 -7
- data/lib/textus/cli.rb +2 -2
- data/lib/textus/container.rb +1 -2
- data/lib/textus/dispatcher.rb +3 -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.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 +18 -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 +23 -18
- data/lib/textus/maintenance/zone_mv.rb +1 -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/server.rb +1 -1
- 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 +14 -10
- data/lib/textus/read/pulse.rb +5 -4
- data/lib/textus/read/validator.rb +1 -1
- 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 +14 -2
- 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} +21 -14
- data/lib/textus/write/mv.rb +15 -3
- data/lib/textus/write/put.rb +14 -2
- data/lib/textus/write/reject.rb +11 -5
- metadata +24 -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
data/lib/textus/errors.rb
CHANGED
|
@@ -90,39 +90,21 @@ module Textus
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
class WriteForbidden < Error
|
|
93
|
-
def initialize(k, z,
|
|
94
|
-
|
|
95
|
-
if
|
|
96
|
-
|
|
93
|
+
def initialize(k, z, verb: nil, holders: nil)
|
|
94
|
+
holders_str =
|
|
95
|
+
if holders && !holders.empty?
|
|
96
|
+
holders.join(", ")
|
|
97
97
|
else
|
|
98
|
-
"
|
|
98
|
+
"no declared role"
|
|
99
99
|
end
|
|
100
100
|
details = { "key" => k, "zone" => z }
|
|
101
|
-
details["
|
|
101
|
+
details["verb"] = verb if verb
|
|
102
|
+
details["holders"] = holders if holders
|
|
102
103
|
super(
|
|
103
104
|
"write_forbidden",
|
|
104
|
-
"zone '#{z}'
|
|
105
|
+
"writing '#{k}' (zone '#{z}') needs capability '#{verb}'",
|
|
105
106
|
details: details,
|
|
106
|
-
hint: "
|
|
107
|
-
)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
class ReadForbidden < Error
|
|
112
|
-
def initialize(k, z, readers: nil)
|
|
113
|
-
readers_str =
|
|
114
|
-
if readers && !readers.empty?
|
|
115
|
-
readers.join(", ")
|
|
116
|
-
else
|
|
117
|
-
"the role(s) listed in the manifest 'read_policy:'"
|
|
118
|
-
end
|
|
119
|
-
details = { "key" => k, "zone" => z }
|
|
120
|
-
details["readers"] = readers if readers
|
|
121
|
-
super(
|
|
122
|
-
"read_forbidden",
|
|
123
|
-
"zone '#{z}' is not readable by role for key '#{k}'",
|
|
124
|
-
details: details,
|
|
125
|
-
hint: "this zone is readable by #{readers_str}; pass --as=<role>",
|
|
107
|
+
hint: "held by: #{holders_str}; pass --as=<role>",
|
|
126
108
|
)
|
|
127
109
|
end
|
|
128
110
|
end
|
|
@@ -163,7 +145,7 @@ module Textus
|
|
|
163
145
|
"invalid_role",
|
|
164
146
|
message || "role '#{r}' is not declared in any zone",
|
|
165
147
|
details: { "role" => r },
|
|
166
|
-
hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under
|
|
148
|
+
hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under roles: (each with a can: list)",
|
|
167
149
|
)
|
|
168
150
|
end
|
|
169
151
|
end
|
|
@@ -204,6 +186,21 @@ module Textus
|
|
|
204
186
|
def initialize(m) = super("proposal_error", m)
|
|
205
187
|
end
|
|
206
188
|
|
|
189
|
+
class GuardFailed < Error
|
|
190
|
+
def initialize(failed)
|
|
191
|
+
# failed: [[predicate_name, reason], ...]
|
|
192
|
+
rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
|
|
193
|
+
names = failed.map(&:first)
|
|
194
|
+
super(
|
|
195
|
+
"guard_failed",
|
|
196
|
+
"guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
|
|
197
|
+
details: { "failed" => rows },
|
|
198
|
+
hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
|
|
199
|
+
"unmet: #{names.join(", ")}",
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
207
204
|
class FlagRenamed < Error
|
|
208
205
|
def initialize(old_flag, new_flag)
|
|
209
206
|
super(
|
|
@@ -10,16 +10,16 @@ module Textus
|
|
|
10
10
|
EVENTS = {
|
|
11
11
|
entry_put: %i[ctx key envelope],
|
|
12
12
|
entry_deleted: %i[ctx key],
|
|
13
|
-
|
|
13
|
+
entry_fetched: %i[ctx key envelope change],
|
|
14
14
|
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
15
15
|
build_completed: %i[ctx key envelope sources],
|
|
16
16
|
proposal_accepted: %i[ctx key target_key],
|
|
17
17
|
proposal_rejected: %i[ctx key target_key],
|
|
18
18
|
file_published: %i[ctx key envelope source target],
|
|
19
19
|
store_loaded: %i[ctx],
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
fetch_started: %i[ctx key mode],
|
|
21
|
+
fetch_failed: %i[ctx key error_class error_message],
|
|
22
|
+
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
|
data/lib/textus/init.rb
CHANGED
|
@@ -2,20 +2,25 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Init
|
|
5
|
-
ZONES = %w[
|
|
5
|
+
ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
8
|
version: textus/3
|
|
9
|
+
roles:
|
|
10
|
+
- { name: human, can: [author, propose] }
|
|
11
|
+
- { name: agent, can: [propose, keep] }
|
|
12
|
+
- { name: automation, can: [fetch, build] }
|
|
9
13
|
zones:
|
|
10
|
-
- { name:
|
|
11
|
-
- { name:
|
|
12
|
-
- { name:
|
|
13
|
-
- { name:
|
|
14
|
-
- { name:
|
|
14
|
+
- { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
|
|
15
|
+
- { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
|
|
16
|
+
- { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
|
|
17
|
+
- { name: proposals, kind: queue, desc: "changes awaiting your accept" }
|
|
18
|
+
- { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
|
|
15
19
|
entries:
|
|
16
|
-
- { key: identity
|
|
17
|
-
- { key:
|
|
18
|
-
- { key:
|
|
20
|
+
- { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
|
|
21
|
+
- { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
|
|
22
|
+
- { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
23
|
+
- { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
19
24
|
YAML
|
|
20
25
|
|
|
21
26
|
HOOKS_README = <<~MD
|
|
@@ -31,12 +36,12 @@ module Textus
|
|
|
31
36
|
```ruby
|
|
32
37
|
Textus.hook do |reg|
|
|
33
38
|
reg.on(:resolve_intake, :my_source) do |config:, args:, **|
|
|
34
|
-
{ _meta: { "
|
|
39
|
+
{ _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
|
|
35
40
|
end
|
|
36
41
|
|
|
37
42
|
reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
38
43
|
reg.on(:validate, :my_check) { |caps:, **| [] }
|
|
39
|
-
reg.on(:entry_put, :my_listener, keys: ["
|
|
44
|
+
reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
|
|
40
45
|
|
|
41
46
|
# Run a side-effect every time textus writes a file to your repo:
|
|
42
47
|
reg.on(:file_published, :notify) do |key:, target:, **|
|
|
@@ -51,25 +56,25 @@ module Textus
|
|
|
51
56
|
|
|
52
57
|
```yaml
|
|
53
58
|
entries:
|
|
54
|
-
- key:
|
|
59
|
+
- key: feeds.foo
|
|
55
60
|
kind: intake
|
|
56
|
-
path:
|
|
57
|
-
zone:
|
|
61
|
+
path: feeds/foo.md
|
|
62
|
+
zone: feeds
|
|
58
63
|
intake:
|
|
59
64
|
handler: my_source
|
|
60
65
|
|
|
61
66
|
rules:
|
|
62
|
-
- match:
|
|
63
|
-
|
|
67
|
+
- match: feeds.foo
|
|
68
|
+
fetch:
|
|
64
69
|
ttl: 10m
|
|
65
70
|
on_stale: timed_sync # warn | sync | timed_sync (default: warn)
|
|
66
71
|
```
|
|
67
72
|
|
|
68
73
|
Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
|
|
69
|
-
:entry_put, :entry_deleted, :
|
|
74
|
+
:entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
|
|
70
75
|
:build_completed, :proposal_accepted, :proposal_rejected,
|
|
71
76
|
:file_published, :store_loaded,
|
|
72
|
-
:
|
|
77
|
+
:fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
|
|
73
78
|
|
|
74
79
|
See SPEC.md §5.10 for the full table.
|
|
75
80
|
MD
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
|
|
16
16
|
def call(from:, to:, dry_run: false)
|
|
17
17
|
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
18
|
-
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.
|
|
18
|
+
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
|
|
19
19
|
|
|
20
20
|
dest_dir = File.join(@root, "zones", to)
|
|
21
21
|
raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
|
|
@@ -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,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.
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def handle_initialize(rid, _params)
|
|
52
|
-
proposer = @store.manifest.policy.
|
|
52
|
+
proposer = @store.manifest.policy.proposer_role
|
|
53
53
|
propose_zone = @store.manifest.policy.propose_zone_for(proposer)
|
|
54
54
|
|
|
55
55
|
@session = Session.new(
|