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/mcp/tools.rb
CHANGED
|
@@ -1,129 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# propagate verbatim so the server can map them to JSON-RPC codes.
|
|
3
|
+
# Thin delegator kept for name stability (ADR 0039). The dispatch table
|
|
4
|
+
# and JSON schemas are now DERIVED from per-verb contracts by MCP::Catalog;
|
|
5
|
+
# this module only forwards.
|
|
7
6
|
module Tools
|
|
8
7
|
module_function
|
|
9
8
|
|
|
10
9
|
def call(name, session:, store:, args:)
|
|
11
|
-
|
|
12
|
-
impl.call(session, store, args || {})
|
|
13
|
-
rescue ContractDrift, CursorExpired
|
|
14
|
-
raise
|
|
15
|
-
rescue Textus::Error => e
|
|
16
|
-
raise ToolError.new("#{name}: #{e.message}")
|
|
10
|
+
Catalog.call(name, session: session, store: store, args: args || {})
|
|
17
11
|
end
|
|
18
|
-
|
|
19
|
-
def ops_for(session, store)
|
|
20
|
-
store.as(session.role)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
REGISTRY = {
|
|
24
|
-
"boot" => ->(_s, store, _a) { store.boot },
|
|
25
|
-
|
|
26
|
-
"find" => lambda do |s, store, args|
|
|
27
|
-
ops_for(s, store).list(zone: args["zone"], prefix: args["prefix"])
|
|
28
|
-
end,
|
|
29
|
-
|
|
30
|
-
"read" => lambda do |s, store, args|
|
|
31
|
-
key = args.fetch("key") { raise ToolError.new("read: missing key") }
|
|
32
|
-
env = ops_for(s, store).get(key)
|
|
33
|
-
env.to_h_for_wire
|
|
34
|
-
end,
|
|
35
|
-
|
|
36
|
-
"tick" => lambda do |s, store, args|
|
|
37
|
-
since = (args["since"] || s.cursor).to_i
|
|
38
|
-
ops_for(s, store).pulse(since: since)
|
|
39
|
-
end,
|
|
40
|
-
|
|
41
|
-
"write" => lambda do |s, store, args|
|
|
42
|
-
key = args.fetch("key") { raise ToolError.new("write: missing key") }
|
|
43
|
-
env = ops_for(s, store).put(
|
|
44
|
-
key,
|
|
45
|
-
meta: args["meta"] || {},
|
|
46
|
-
body: args["body"],
|
|
47
|
-
content: args["content"],
|
|
48
|
-
if_etag: args["if_etag"],
|
|
49
|
-
)
|
|
50
|
-
{ "uid" => env.uid, "etag" => env.etag }
|
|
51
|
-
end,
|
|
52
|
-
|
|
53
|
-
"propose" => lambda do |s, store, args|
|
|
54
|
-
raise ToolError.new("propose: session has no propose_zone") unless s.propose_zone
|
|
55
|
-
|
|
56
|
-
rel = args.fetch("key") { raise ToolError.new("propose: missing key") }
|
|
57
|
-
target = "#{s.propose_zone}.#{rel}"
|
|
58
|
-
env = ops_for(s, store).put(
|
|
59
|
-
target,
|
|
60
|
-
meta: args["meta"] || {},
|
|
61
|
-
body: args["body"],
|
|
62
|
-
content: args["content"],
|
|
63
|
-
)
|
|
64
|
-
{ "uid" => env.uid, "etag" => env.etag, "key" => target }
|
|
65
|
-
end,
|
|
66
|
-
|
|
67
|
-
"refresh" => lambda do |s, store, args|
|
|
68
|
-
key = args.fetch("key") { raise ToolError.new("refresh: missing key") }
|
|
69
|
-
outcome = ops_for(s, store).refresh(key)
|
|
70
|
-
{ "outcome" => outcome.class.name.split("::").last.downcase }
|
|
71
|
-
end,
|
|
72
|
-
|
|
73
|
-
"refresh_stale" => lambda do |s, store, args|
|
|
74
|
-
ops_for(s, store).refresh_all(zone: args["zone"], prefix: args["prefix"])
|
|
75
|
-
end,
|
|
76
|
-
|
|
77
|
-
"schema" => lambda do |_s, store, args|
|
|
78
|
-
family = args.fetch("family") { raise ToolError.new("schema: missing family") }
|
|
79
|
-
store.schemas.fetch(family)
|
|
80
|
-
end,
|
|
81
|
-
|
|
82
|
-
"rules" => lambda do |_s, store, args|
|
|
83
|
-
key = args.fetch("key") { raise ToolError.new("rules: missing key") }
|
|
84
|
-
set = store.manifest.rules.for(key)
|
|
85
|
-
{
|
|
86
|
-
"refresh" => set.refresh&.to_h,
|
|
87
|
-
"promote" => set.respond_to?(:promote) ? set.promote&.to_h : nil,
|
|
88
|
-
}.compact
|
|
89
|
-
end,
|
|
90
|
-
|
|
91
|
-
"key_mv_prefix" => lambda do |s, store, args|
|
|
92
|
-
ops_for(s, store).key_mv_prefix(
|
|
93
|
-
from_prefix: args.fetch("from_prefix") { raise ToolError.new("key_mv_prefix: missing from_prefix") },
|
|
94
|
-
to_prefix: args.fetch("to_prefix") { raise ToolError.new("key_mv_prefix: missing to_prefix") },
|
|
95
|
-
dry_run: args["dry_run"] || false,
|
|
96
|
-
).to_h
|
|
97
|
-
end,
|
|
98
|
-
|
|
99
|
-
"key_delete_prefix" => lambda do |s, store, args|
|
|
100
|
-
ops_for(s, store).key_delete_prefix(
|
|
101
|
-
prefix: args.fetch("prefix") { raise ToolError.new("key_delete_prefix: missing prefix") },
|
|
102
|
-
dry_run: args["dry_run"] || false,
|
|
103
|
-
).to_h
|
|
104
|
-
end,
|
|
105
|
-
|
|
106
|
-
"zone_mv" => lambda do |s, store, args|
|
|
107
|
-
ops_for(s, store).zone_mv(
|
|
108
|
-
from: args.fetch("from") { raise ToolError.new("zone_mv: missing from") },
|
|
109
|
-
to: args.fetch("to") { raise ToolError.new("zone_mv: missing to") },
|
|
110
|
-
dry_run: args["dry_run"] || false,
|
|
111
|
-
).to_h
|
|
112
|
-
end,
|
|
113
|
-
|
|
114
|
-
"rule_lint" => lambda do |s, store, args|
|
|
115
|
-
ops_for(s, store).rule_lint(
|
|
116
|
-
candidate_yaml: args.fetch("candidate_yaml") { raise ToolError.new("rule_lint: missing candidate_yaml") },
|
|
117
|
-
).to_h
|
|
118
|
-
end,
|
|
119
|
-
|
|
120
|
-
"migrate" => lambda do |s, store, args|
|
|
121
|
-
ops_for(s, store).migrate(
|
|
122
|
-
plan_yaml: args.fetch("plan_yaml") { raise ToolError.new("migrate: missing plan_yaml") },
|
|
123
|
-
dry_run: args["dry_run"] || false,
|
|
124
|
-
).to_h
|
|
125
|
-
end,
|
|
126
|
-
}.freeze
|
|
127
12
|
end
|
|
128
13
|
end
|
|
129
14
|
end
|
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
|
|
11
11
|
def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
|
|
12
12
|
@root = root
|
|
13
|
-
@path =
|
|
13
|
+
@path = Textus::Layout.audit_log(root)
|
|
14
14
|
@max_size = max_size
|
|
15
15
|
@keep = keep
|
|
16
16
|
end
|
|
@@ -54,6 +54,7 @@ module Textus
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
57
58
|
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
58
59
|
f.flock(File::LOCK_EX)
|
|
59
60
|
next_seq = current_max_seq_unlocked + 1
|
|
@@ -81,6 +82,14 @@ module Textus
|
|
|
81
82
|
|
|
82
83
|
private
|
|
83
84
|
|
|
85
|
+
def rotated(n)
|
|
86
|
+
File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def rotated_meta(n)
|
|
90
|
+
File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}.meta.json")
|
|
91
|
+
end
|
|
92
|
+
|
|
84
93
|
# Caller holds the flock. Returns the highest seq across the active log,
|
|
85
94
|
# OR the most-recent rotated file's max_seq if the active log is empty.
|
|
86
95
|
def current_max_seq_unlocked
|
|
@@ -113,7 +122,7 @@ module Textus
|
|
|
113
122
|
end
|
|
114
123
|
|
|
115
124
|
def read_meta(n)
|
|
116
|
-
path =
|
|
125
|
+
path = rotated_meta(n)
|
|
117
126
|
return nil unless File.exist?(path)
|
|
118
127
|
|
|
119
128
|
JSON.parse(File.read(path))
|
|
@@ -151,25 +160,18 @@ module Textus
|
|
|
151
160
|
meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
|
|
152
161
|
|
|
153
162
|
# Drop the file that would be shifted past @keep.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
FileUtils.rm_f(oldest)
|
|
157
|
-
FileUtils.rm_f(oldest_meta)
|
|
163
|
+
FileUtils.rm_f(rotated(@keep))
|
|
164
|
+
FileUtils.rm_f(rotated_meta(@keep))
|
|
158
165
|
|
|
159
166
|
# Shift .N → .(N+1) for N = keep-1 down to 1.
|
|
160
167
|
(@keep - 1).downto(1) do |n|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
File.rename(src, dst) if File.exist?(src)
|
|
164
|
-
|
|
165
|
-
src_meta = File.join(@root, "audit.log.#{n}.meta.json")
|
|
166
|
-
dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
|
|
167
|
-
File.rename(src_meta, dst_meta) if File.exist?(src_meta)
|
|
168
|
+
File.rename(rotated(n), rotated(n + 1)) if File.exist?(rotated(n))
|
|
169
|
+
File.rename(rotated_meta(n), rotated_meta(n + 1)) if File.exist?(rotated_meta(n))
|
|
168
170
|
end
|
|
169
171
|
|
|
170
172
|
# Active log → .1
|
|
171
|
-
File.rename(@path,
|
|
172
|
-
File.write(
|
|
173
|
+
File.rename(@path, rotated(1))
|
|
174
|
+
File.write(rotated_meta(1), JSON.generate(meta) + "\n")
|
|
173
175
|
# Next append will create a fresh audit.log via File::CREAT.
|
|
174
176
|
end
|
|
175
177
|
|
|
@@ -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
|
|
@@ -5,7 +5,6 @@ require "time"
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Ports
|
|
7
7
|
class BuildLock
|
|
8
|
-
LOCK_FILENAME = ".build.lock"
|
|
9
8
|
MAX_HOLDER_BYTES = 512
|
|
10
9
|
|
|
11
10
|
def self.with(root:, &)
|
|
@@ -13,7 +12,7 @@ module Textus
|
|
|
13
12
|
end
|
|
14
13
|
|
|
15
14
|
def initialize(root:)
|
|
16
|
-
@path =
|
|
15
|
+
@path = Textus::Layout.build_lock(root)
|
|
17
16
|
@file = nil
|
|
18
17
|
end
|
|
19
18
|
|
|
@@ -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)
|
|
@@ -2,12 +2,12 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Ports
|
|
5
|
-
module
|
|
5
|
+
module Fetch
|
|
6
6
|
class Lock
|
|
7
7
|
def initialize(root:, key:)
|
|
8
8
|
@root = root
|
|
9
9
|
@key = key
|
|
10
|
-
@path = File.join(
|
|
10
|
+
@path = File.join(Textus::Layout.locks(root), "#{safe_key}.lock")
|
|
11
11
|
@file = nil
|
|
12
12
|
end
|
|
13
13
|
|
data/lib/textus/projection.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
10
10
|
# semantics: pure read (`ops.get`) for materialization paths;
|
|
11
|
-
# `ops.
|
|
11
|
+
# `ops.get_or_fetch` if you want fetch-on-stale.
|
|
12
12
|
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
13
13
|
# `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
|
|
14
14
|
# `transform_context` — capability object handed to transform reducers as `caps:`.
|
data/lib/textus/read/audit.rb
CHANGED
|
@@ -3,7 +3,7 @@ require "time"
|
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
5
|
module Read
|
|
6
|
-
# Queries .textus/audit.log. Filters: key, zone, role, verb, since,
|
|
6
|
+
# Queries .textus/.run/audit/audit.log. Filters: key, zone, role, verb, since,
|
|
7
7
|
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
8
8
|
# rows produce nil and are skipped).
|
|
9
9
|
class Audit
|
|
@@ -33,7 +33,7 @@ module Textus
|
|
|
33
33
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
34
34
|
@manifest = container.manifest
|
|
35
35
|
@root = container.root
|
|
36
|
-
@log_path =
|
|
36
|
+
@log_path = Textus::Layout.audit_log(container.root)
|
|
37
37
|
@audit_log = container.audit_log
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -84,7 +84,7 @@ module Textus
|
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def all_log_files
|
|
87
|
-
rotated = Dir.glob(File.join(@root, "audit.log.*"))
|
|
87
|
+
rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
|
|
88
88
|
.reject { |p| p.end_with?(".meta.json") }
|
|
89
89
|
.sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
|
|
90
90
|
active = File.exist?(@log_path) ? [@log_path] : []
|
data/lib/textus/read/boot.rb
CHANGED
|
@@ -5,6 +5,12 @@ module Textus
|
|
|
5
5
|
# (container:, call:) entry point that Dispatcher::VERBS resolves to.
|
|
6
6
|
# Boot is role-independent, so `call` is not consulted.
|
|
7
7
|
class Boot
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :boot
|
|
11
|
+
summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
|
|
12
|
+
surfaces :cli, :ruby, :mcp
|
|
13
|
+
|
|
8
14
|
def initialize(container:, call:)
|
|
9
15
|
@container = container
|
|
10
16
|
@call = call
|
|
@@ -3,8 +3,8 @@ require "time"
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Read
|
|
5
5
|
# Per-entry freshness report. Walks every entry declared in the manifest,
|
|
6
|
-
# consults `rules_for(key)` for a
|
|
7
|
-
# current status. Status is one of :fresh, :stale, :
|
|
6
|
+
# consults `rules_for(key)` for a fetch rule, and reports the
|
|
7
|
+
# current status. Status is one of :fresh, :stale, :never_fetched, or
|
|
8
8
|
# :no_policy.
|
|
9
9
|
class Freshness
|
|
10
10
|
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
@@ -16,7 +16,7 @@ module Textus
|
|
|
16
16
|
@cache = {}
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
# Returns the soonest `next_due_at` across all entries with a
|
|
19
|
+
# Returns the soonest `next_due_at` across all entries with a fetch
|
|
20
20
|
# policy, as an ISO-8601 string, or nil if none.
|
|
21
21
|
def soonest_due(prefix: nil, zone: nil)
|
|
22
22
|
times = call(prefix: prefix, zone: zone)
|
|
@@ -43,17 +43,17 @@ module Textus
|
|
|
43
43
|
|
|
44
44
|
def row_for(mentry)
|
|
45
45
|
set = @manifest.rules.for(mentry.key)
|
|
46
|
-
|
|
46
|
+
fetch = set.fetch
|
|
47
47
|
envelope = safe_get(mentry.key)
|
|
48
|
-
last = envelope&.meta&.dig("
|
|
48
|
+
last = envelope&.meta&.dig("last_fetched_at")
|
|
49
49
|
|
|
50
|
-
return base_row(mentry, last).merge(status: :no_policy) if
|
|
50
|
+
return base_row(mentry, last).merge(status: :no_policy) if fetch.nil?
|
|
51
51
|
|
|
52
|
-
fp =
|
|
52
|
+
fp = fetch.to_freshness_policy
|
|
53
53
|
cache_key = [mentry.key, last]
|
|
54
54
|
verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
|
|
55
55
|
status = if verdict.fresh? then :fresh
|
|
56
|
-
elsif last.nil? then :
|
|
56
|
+
elsif last.nil? then :never_fetched
|
|
57
57
|
else :stale
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -69,7 +69,7 @@ module Textus
|
|
|
69
69
|
{
|
|
70
70
|
key: mentry.key,
|
|
71
71
|
zone: mentry.zone,
|
|
72
|
-
|
|
72
|
+
last_fetched_at: last,
|
|
73
73
|
age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
|
|
74
74
|
}
|
|
75
75
|
end
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
# Pure read: returns the on-disk envelope annotated with a freshness
|
|
4
|
-
# verdict. Never triggers
|
|
4
|
+
# verdict. Never triggers fetch; never invokes the orchestrator.
|
|
5
5
|
#
|
|
6
|
-
# For interactive reads that want
|
|
7
|
-
# `Read::
|
|
6
|
+
# For interactive reads that want fetch-on-stale, use
|
|
7
|
+
# `Read::GetOrFetch`, which composes this with the orchestrator.
|
|
8
8
|
class Get
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :get
|
|
12
|
+
summary "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness)."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :key, String, required: true, positional: true
|
|
15
|
+
response(&:to_h_for_wire)
|
|
16
|
+
|
|
9
17
|
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
10
18
|
@container = container
|
|
11
19
|
@call = call
|
|
@@ -19,16 +27,16 @@ module Textus
|
|
|
19
27
|
return nil if envelope.nil?
|
|
20
28
|
|
|
21
29
|
policy_set = @manifest.rules.for(key)
|
|
22
|
-
|
|
23
|
-
return annotate_fresh(envelope) if
|
|
30
|
+
fetch_policy = policy_set.fetch
|
|
31
|
+
return annotate_fresh(envelope) if fetch_policy.nil?
|
|
24
32
|
|
|
25
|
-
policy =
|
|
33
|
+
policy = fetch_policy.to_freshness_policy
|
|
26
34
|
verdict = @evaluator.call(policy, envelope, now: @call.now)
|
|
27
35
|
|
|
28
36
|
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
29
37
|
stale: verdict.stale?,
|
|
30
38
|
reason: verdict.reason,
|
|
31
|
-
|
|
39
|
+
fetching: false,
|
|
32
40
|
))
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -58,7 +66,7 @@ module Textus
|
|
|
58
66
|
|
|
59
67
|
def annotate_fresh(envelope)
|
|
60
68
|
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
61
|
-
stale: false, reason: nil,
|
|
69
|
+
stale: false, reason: nil, fetching: false,
|
|
62
70
|
))
|
|
63
71
|
end
|
|
64
72
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
-
# Composes pure `Read::Get` with the
|
|
3
|
+
# Composes pure `Read::Get` with the fetch orchestrator: runs Get
|
|
4
4
|
# to obtain the envelope and freshness verdict, then if the verdict
|
|
5
5
|
# is stale and the rule's `on_stale` policy demands action, hands
|
|
6
6
|
# off to the orchestrator. Use for interactive reads where the
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
#
|
|
9
9
|
# Pure reads (build, projection, schema tooling) should use
|
|
10
10
|
# `Read::Get` directly; it has no orchestrator dependency.
|
|
11
|
-
class
|
|
11
|
+
class GetOrFetch
|
|
12
12
|
def initialize(container:, call:, get: nil, orchestrator: nil)
|
|
13
13
|
@container = container
|
|
14
14
|
@call = call
|
|
@@ -24,10 +24,10 @@ module Textus
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def build_orchestrator
|
|
27
|
-
worker = Textus::Write::
|
|
27
|
+
worker = Textus::Write::FetchWorker.new(
|
|
28
28
|
container: @container, call: @call,
|
|
29
29
|
)
|
|
30
|
-
Textus::Write::
|
|
30
|
+
Textus::Write::FetchOrchestrator.new(
|
|
31
31
|
worker: worker, store_root: @container.root, events: @container.events,
|
|
32
32
|
hook_context: hook_context
|
|
33
33
|
)
|
|
@@ -41,10 +41,10 @@ module Textus
|
|
|
41
41
|
return envelope unless envelope.freshness&.stale
|
|
42
42
|
|
|
43
43
|
policy_set = @manifest.rules.for(key)
|
|
44
|
-
|
|
45
|
-
return envelope if
|
|
44
|
+
fetch_policy = policy_set.fetch
|
|
45
|
+
return envelope if fetch_policy.nil?
|
|
46
46
|
|
|
47
|
-
policy =
|
|
47
|
+
policy = fetch_policy.to_freshness_policy
|
|
48
48
|
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
49
49
|
action = policy.decide(verdict)
|
|
50
50
|
outcome = @orchestrator.execute(action, key: key)
|
|
@@ -52,15 +52,15 @@ module Textus
|
|
|
52
52
|
case outcome
|
|
53
53
|
when Textus::Domain::Outcome::Skipped
|
|
54
54
|
envelope
|
|
55
|
-
when Textus::Domain::Outcome::
|
|
55
|
+
when Textus::Domain::Outcome::Fetched
|
|
56
56
|
outcome.envelope.with(
|
|
57
|
-
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil,
|
|
57
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
|
|
58
58
|
)
|
|
59
59
|
when Textus::Domain::Outcome::Detached
|
|
60
|
-
envelope.with(freshness: envelope.freshness.with(
|
|
60
|
+
envelope.with(freshness: envelope.freshness.with(fetching: true))
|
|
61
61
|
when Textus::Domain::Outcome::Failed
|
|
62
62
|
envelope.with(
|
|
63
|
-
freshness: envelope.freshness.with(
|
|
63
|
+
freshness: envelope.freshness.with(fetch_error: outcome.error.message),
|
|
64
64
|
)
|
|
65
65
|
end
|
|
66
66
|
end
|
data/lib/textus/read/list.rb
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class List
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :list
|
|
7
|
+
summary "List keys filtered by zone and/or prefix."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :prefix, String
|
|
10
|
+
arg :zone, String
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
|
@@ -1,40 +1,44 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
# For one key, surface every matching policy block along with the
|
|
4
|
-
# per-slot effective value (which loses ties win-by-specificity)
|
|
4
|
+
# per-slot effective value (which loses ties win-by-specificity) and the
|
|
5
|
+
# effective guard predicate names for every write transition (ADR 0031).
|
|
5
6
|
class PolicyExplain
|
|
6
7
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
8
|
@manifest = container.manifest
|
|
9
|
+
@schemas = container.schemas
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def call(key:)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
matching = @manifest.rules.explain(key)
|
|
14
|
+
winners = @manifest.rules.for(key)
|
|
15
|
+
factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
|
|
14
16
|
|
|
15
17
|
{
|
|
16
18
|
key: key,
|
|
17
19
|
matched_blocks: matching.map do |b|
|
|
18
20
|
{
|
|
19
21
|
match: b.match,
|
|
20
|
-
|
|
22
|
+
fetch: !b.fetch.nil?,
|
|
21
23
|
handler_allowlist: !b.handler_allowlist.nil?,
|
|
22
|
-
|
|
24
|
+
guard: !b.guard.nil?,
|
|
23
25
|
retention: !b.retention.nil?,
|
|
24
26
|
}
|
|
25
27
|
end,
|
|
26
28
|
effective: {
|
|
27
|
-
|
|
28
|
-
ttl_seconds: winners.
|
|
29
|
-
on_stale: winners.
|
|
29
|
+
fetch: winners.fetch && {
|
|
30
|
+
ttl_seconds: winners.fetch.ttl_seconds,
|
|
31
|
+
on_stale: winners.fetch.on_stale,
|
|
30
32
|
},
|
|
31
33
|
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
32
|
-
promotion: winners.promote && { requires: winners.promote.requires },
|
|
33
34
|
retention: winners.retention && {
|
|
34
35
|
expire_after: winners.retention.expire_after,
|
|
35
36
|
archive_after: winners.retention.archive_after,
|
|
36
37
|
},
|
|
37
38
|
},
|
|
39
|
+
guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
|
|
40
|
+
[transition, factory.for(transition, key).predicates.map(&:name)]
|
|
41
|
+
end,
|
|
38
42
|
}
|
|
39
43
|
end
|
|
40
44
|
end
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -7,6 +7,13 @@ module Textus
|
|
|
7
7
|
# APIs; pulse is sugar with a stable envelope shape and a monotonic
|
|
8
8
|
# cursor (seq).
|
|
9
9
|
class Pulse
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :pulse
|
|
13
|
+
summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
|
|
14
|
+
surfaces :cli, :ruby, :mcp
|
|
15
|
+
arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
|
|
16
|
+
|
|
10
17
|
def initialize(container:, call:)
|
|
11
18
|
@container = container
|
|
12
19
|
@call = call
|
|
@@ -49,11 +56,12 @@ module Textus
|
|
|
49
56
|
end
|
|
50
57
|
|
|
51
58
|
def review_keys
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
59
|
+
# The single queue zone (kind: queue; schema guarantees ≤1), derived
|
|
60
|
+
# from the manifest rather than a hardcoded zone name (ADR 0034 / D1).
|
|
61
|
+
queue = @manifest.policy.queue_zone
|
|
62
|
+
return [] unless queue
|
|
55
63
|
|
|
56
|
-
rows = Read::List.new(container: @container).call(zone:
|
|
64
|
+
rows = Read::List.new(container: @container).call(zone: queue)
|
|
57
65
|
rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
58
66
|
end
|
|
59
67
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Effective rule set (fetch + guard) for a key. Was the inlined MCP
|
|
4
|
+
# `rules` tool; promoted to a first-class verb so MCP is a pure projection
|
|
5
|
+
# (ADR 0039).
|
|
6
|
+
class Rules
|
|
7
|
+
extend Textus::Contract::DSL
|
|
8
|
+
|
|
9
|
+
verb :rules
|
|
10
|
+
summary "Return effective rules for a key (fetch, guard, ...)."
|
|
11
|
+
surfaces :ruby, :mcp
|
|
12
|
+
arg :key, String, required: true, positional: true
|
|
13
|
+
|
|
14
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
@manifest = container.manifest
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(key)
|
|
19
|
+
set = @manifest.rules.for(key)
|
|
20
|
+
{ "fetch" => set.fetch&.to_h, "guard" => set.guard }.compact
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class SchemaEnvelope
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :schema
|
|
7
|
+
summary "Return the schema (field shape) for an entry's family, by key."
|
|
8
|
+
surfaces :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true
|
|
10
|
+
|
|
4
11
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
12
|
@manifest = container.manifest
|
|
6
13
|
@schemas = container.schemas
|
|
@@ -54,7 +54,7 @@ module Textus
|
|
|
54
54
|
last_writer = @audit_log.last_writer_for(key)
|
|
55
55
|
return if last_writer.nil?
|
|
56
56
|
|
|
57
|
-
last_writer_is_authority = @manifest.policy.
|
|
57
|
+
last_writer_is_authority = @manifest.policy.roles_with_capability("author").include?(last_writer)
|
|
58
58
|
|
|
59
59
|
env.meta.each_key do |field|
|
|
60
60
|
owner = schema.maintained_by(field)
|
data/lib/textus/role.rb
CHANGED
|
@@ -2,9 +2,13 @@ module Textus
|
|
|
2
2
|
module Role
|
|
3
3
|
PATTERN = /\A[a-z][a-z0-9_-]*\z/
|
|
4
4
|
DEFAULT = "human".freeze
|
|
5
|
+
# The default acting identity for the MCP transport (ADR 0040): an agent
|
|
6
|
+
# over stdio proposes; it does not inherit the human's authority. CLI
|
|
7
|
+
# callers keep the `human` DEFAULT.
|
|
8
|
+
AGENT = "agent".freeze
|
|
5
9
|
|
|
6
|
-
def self.resolve(root:, flag: nil, env: ENV)
|
|
7
|
-
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) ||
|
|
10
|
+
def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
|
|
11
|
+
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
|
|
8
12
|
raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
|
|
9
13
|
|
|
10
14
|
candidate
|