textus 0.43.2 → 0.45.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/CHANGELOG.md +57 -0
- data/README.md +3 -3
- data/SPEC.md +15 -13
- data/docs/architecture/README.md +28 -9
- data/docs/reference/conventions.md +8 -9
- data/lib/textus/boot.rb +3 -4
- data/lib/textus/cli/group/fetch.rb +2 -2
- data/lib/textus/cli/group.rb +1 -0
- data/lib/textus/cli/runner.rb +187 -0
- data/lib/textus/cli/verb/build.rb +4 -4
- data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
- data/lib/textus/cli/verb/get.rb +6 -5
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb.rb +3 -0
- data/lib/textus/cli.rb +8 -2
- data/lib/textus/contract/around.rb +29 -0
- data/lib/textus/contract/binder.rb +88 -0
- data/lib/textus/contract/resources/cursor.rb +26 -0
- data/lib/textus/contract/sources.rb +39 -0
- data/lib/textus/contract/view.rb +15 -0
- data/lib/textus/contract.rb +68 -8
- data/lib/textus/dispatcher.rb +5 -6
- data/lib/textus/hooks/context.rb +24 -2
- data/lib/textus/maintenance/key_delete_prefix.rb +6 -4
- data/lib/textus/maintenance/key_mv_prefix.rb +7 -5
- data/lib/textus/maintenance/migrate.rb +12 -9
- data/lib/textus/maintenance/rule_lint.rb +4 -3
- data/lib/textus/maintenance/zone_mv.rb +7 -5
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/mcp/catalog.rb +6 -33
- data/lib/textus/projection.rb +2 -2
- data/lib/textus/read/audit.rb +19 -0
- data/lib/textus/read/blame.rb +11 -1
- data/lib/textus/read/deps.rb +15 -1
- data/lib/textus/read/doctor.rb +8 -0
- data/lib/textus/read/freshness.rb +10 -0
- data/lib/textus/read/get.rb +86 -21
- data/lib/textus/read/list.rb +1 -0
- data/lib/textus/read/published.rb +7 -0
- data/lib/textus/read/pulse.rb +1 -0
- data/lib/textus/read/rdeps.rb +14 -0
- data/lib/textus/read/rule_explain.rb +84 -0
- data/lib/textus/read/rule_list.rb +39 -0
- data/lib/textus/read/schema_envelope.rb +3 -2
- data/lib/textus/read/uid.rb +9 -0
- data/lib/textus/read/where.rb +8 -0
- data/lib/textus/role_scope.rb +34 -6
- data/lib/textus/schema/tools.rb +12 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +8 -0
- data/lib/textus/write/{publish.rb → build.rb} +16 -7
- data/lib/textus/write/delete.rb +13 -0
- data/lib/textus/write/fetch_all.rb +1 -0
- data/lib/textus/write/fetch_orchestrator.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +1 -1
- data/lib/textus/write/mv.rb +16 -0
- data/lib/textus/write/propose.rb +7 -2
- data/lib/textus/write/put.rb +2 -2
- data/lib/textus/write/reject.rb +8 -0
- data/lib/textus/write/retention_sweep.rb +9 -0
- metadata +11 -29
- data/lib/textus/cli/verb/accept.rb +0 -16
- data/lib/textus/cli/verb/audit.rb +0 -34
- data/lib/textus/cli/verb/blame.rb +0 -17
- data/lib/textus/cli/verb/delete.rb +0 -17
- data/lib/textus/cli/verb/deps.rb +0 -14
- data/lib/textus/cli/verb/freshness.rb +0 -17
- data/lib/textus/cli/verb/key_delete.rb +0 -24
- data/lib/textus/cli/verb/list.rb +0 -16
- data/lib/textus/cli/verb/migrate.rb +0 -18
- data/lib/textus/cli/verb/mv.rb +0 -27
- data/lib/textus/cli/verb/propose.rb +0 -28
- data/lib/textus/cli/verb/published.rb +0 -13
- data/lib/textus/cli/verb/pulse.rb +0 -26
- data/lib/textus/cli/verb/rdeps.rb +0 -14
- data/lib/textus/cli/verb/reject.rb +0 -16
- data/lib/textus/cli/verb/retain.rb +0 -19
- data/lib/textus/cli/verb/rule_explain.rb +0 -16
- data/lib/textus/cli/verb/rule_lint.rb +0 -18
- data/lib/textus/cli/verb/rule_list.rb +0 -29
- data/lib/textus/cli/verb/schema.rb +0 -15
- data/lib/textus/cli/verb/uid.rb +0 -15
- data/lib/textus/cli/verb/where.rb +0 -14
- data/lib/textus/cli/verb/zone_mv.rb +0 -19
- data/lib/textus/read/get_or_fetch.rb +0 -69
- data/lib/textus/read/policy_explain.rb +0 -46
- data/lib/textus/read/rules.rb +0 -25
data/lib/textus/projection.rb
CHANGED
|
@@ -7,8 +7,8 @@ module Textus
|
|
|
7
7
|
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
8
|
|
|
9
9
|
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
10
|
-
# semantics: pure read (`
|
|
11
|
-
# `ops.
|
|
10
|
+
# semantics: pure read (`Read::Get.new(...).call(key)`, fetch:false default) for
|
|
11
|
+
# materialization paths; `ops.get` (read-through, fetch:true injected) for 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
|
@@ -30,6 +30,25 @@ module Textus
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
extend Textus::Contract::DSL
|
|
34
|
+
|
|
35
|
+
verb :audit
|
|
36
|
+
summary "Query the audit log with optional filters."
|
|
37
|
+
surfaces :cli, :ruby
|
|
38
|
+
cli "audit"
|
|
39
|
+
# #call(**filters) — args map to Query.build keyword params (ADR 0063)
|
|
40
|
+
arg :key, String, required: false, description: "filter to rows for this key"
|
|
41
|
+
arg :zone, String, required: false, description: "filter to keys in this zone"
|
|
42
|
+
arg :role, String, required: false, description: "filter to rows written under this role"
|
|
43
|
+
arg :verb, String, required: false, description: "filter to rows for this verb"
|
|
44
|
+
arg :since, String, required: false,
|
|
45
|
+
coerce: ->(s) { Textus::Read::Audit.parse_since(s, now: Time.now) },
|
|
46
|
+
description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
|
|
47
|
+
arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
|
|
48
|
+
arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
|
|
49
|
+
arg :limit, Integer, required: false, description: "maximum number of rows to return"
|
|
50
|
+
view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
|
|
51
|
+
|
|
33
52
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
34
53
|
@manifest = container.manifest
|
|
35
54
|
@root = container.root
|
data/lib/textus/read/blame.rb
CHANGED
|
@@ -7,13 +7,23 @@ module Textus
|
|
|
7
7
|
# row. Falls back to `git => nil` when not in a git repo or when the
|
|
8
8
|
# file is untracked.
|
|
9
9
|
class Blame
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :blame
|
|
13
|
+
summary "Annotate audit rows for a key with the git commit that introduced each file state."
|
|
14
|
+
surfaces :cli, :ruby
|
|
15
|
+
cli "blame"
|
|
16
|
+
arg :key, String, required: true, positional: true, description: "entry key to blame"
|
|
17
|
+
arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
|
|
18
|
+
view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
|
|
19
|
+
|
|
10
20
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
21
|
@container = container
|
|
12
22
|
@manifest = container.manifest
|
|
13
23
|
@root = container.root
|
|
14
24
|
end
|
|
15
25
|
|
|
16
|
-
def call(key
|
|
26
|
+
def call(key, limit: nil)
|
|
17
27
|
audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
|
|
18
28
|
path = resolve_path(key)
|
|
19
29
|
return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
|
data/lib/textus/read/deps.rb
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Deps
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :deps
|
|
7
|
+
summary "List the keys a derived entry depends on (its projection/external sources)."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true,
|
|
10
|
+
description: "dotted key of the derived entry whose source keys you want"
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
|
7
15
|
|
|
8
16
|
def call(key)
|
|
9
|
-
|
|
17
|
+
{ "key" => key, "deps" => sources_for(key) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def sources_for(key)
|
|
23
|
+
entry = @manifest.data.entries.find { |e| e.key == key }
|
|
10
24
|
return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
25
|
|
|
12
26
|
src = entry.source
|
data/lib/textus/read/doctor.rb
CHANGED
|
@@ -6,6 +6,14 @@ module Textus
|
|
|
6
6
|
# The acting role is irrelevant to a read-only health check, so `call`
|
|
7
7
|
# is not consulted.
|
|
8
8
|
class Doctor
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :doctor
|
|
12
|
+
summary "Run health checks on the textus store and report any issues."
|
|
13
|
+
surfaces :cli, :ruby
|
|
14
|
+
cli "doctor"
|
|
15
|
+
arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
|
|
16
|
+
|
|
9
17
|
def initialize(container:, call:)
|
|
10
18
|
@container = container
|
|
11
19
|
@call = call
|
|
@@ -7,6 +7,16 @@ module Textus
|
|
|
7
7
|
# current status. Status is one of :fresh, :stale, :never_fetched, or
|
|
8
8
|
# :no_policy.
|
|
9
9
|
class Freshness
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :freshness
|
|
13
|
+
summary "Report the fetch-freshness status of every entry with a fetch policy."
|
|
14
|
+
surfaces :cli, :ruby
|
|
15
|
+
cli "freshness"
|
|
16
|
+
arg :prefix, String, required: false, description: "filter to keys with this prefix"
|
|
17
|
+
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
18
|
+
view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
|
|
19
|
+
|
|
10
20
|
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
11
21
|
@container = container
|
|
12
22
|
@call = call
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -1,54 +1,103 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# The one read path. `fetch:` controls behavior:
|
|
4
|
+
# fetch: false (default) — pure read: the on-disk envelope annotated with
|
|
5
|
+
# a freshness verdict. NEVER builds the orchestrator (no threads/forks/
|
|
6
|
+
# locks/events). This is the safe default for direct (in-process)
|
|
7
|
+
# callers — accept/reject/publish, materializer, uid, validate_all/
|
|
8
|
+
# validator, schema tooling, and the hook context — that must read
|
|
9
|
+
# persisted truth without triggering a fetch.
|
|
10
|
+
# fetch: true — read-through: after a stale verdict, hands off to the
|
|
11
|
+
# fetch orchestrator per the entry's fetch rule (degrades to the pure
|
|
12
|
+
# result when the key has no rule).
|
|
5
13
|
#
|
|
6
|
-
#
|
|
7
|
-
# `
|
|
14
|
+
# The public `get` verb is read-through because the contract declares
|
|
15
|
+
# `arg :fetch, default: true`, injected on every verb surface (RoleScope +
|
|
16
|
+
# MCP map_args, ADR 0062 amendment). Direct construction bypasses that
|
|
17
|
+
# injection and so gets the safe `fetch: false` method default.
|
|
8
18
|
class Get
|
|
9
19
|
extend Textus::Contract::DSL
|
|
10
20
|
|
|
11
21
|
verb :get
|
|
12
|
-
summary "Read one entry.
|
|
22
|
+
summary "Read one entry. Read-through by default — fetches on stale per " \
|
|
23
|
+
"the entry's fetch rule, degrading to a pure read when the key " \
|
|
24
|
+
"has no rule. Pass fetch:false for a guaranteed pure on-disk " \
|
|
25
|
+
"read. Returns the envelope (uid, etag, _meta, body, freshness)."
|
|
13
26
|
surfaces :cli, :ruby, :mcp
|
|
14
27
|
arg :key, String, required: true, positional: true,
|
|
15
28
|
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
16
|
-
|
|
29
|
+
arg :fetch, :boolean, default: true,
|
|
30
|
+
description: "read-through (fetch on stale per the " \
|
|
31
|
+
"entry's fetch rule) when true, the default; " \
|
|
32
|
+
"false returns the on-disk envelope without ever fetching"
|
|
33
|
+
view { |v, _i| v.to_h_for_wire }
|
|
17
34
|
|
|
18
|
-
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
35
|
+
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator, orchestrator: nil)
|
|
19
36
|
@container = container
|
|
20
37
|
@call = call
|
|
21
38
|
@manifest = container.manifest
|
|
22
39
|
@file_store = container.file_store
|
|
23
40
|
@evaluator = evaluator
|
|
41
|
+
@orchestrator = orchestrator # nil → built lazily on first fetch only
|
|
24
42
|
end
|
|
25
43
|
|
|
26
|
-
def call(key)
|
|
44
|
+
def call(key, fetch: false)
|
|
45
|
+
envelope = annotated_envelope(key)
|
|
46
|
+
return envelope if envelope.nil?
|
|
47
|
+
return envelope unless fetch && envelope.freshness&.stale
|
|
48
|
+
|
|
49
|
+
fetch_policy = fetch_policy_for(key)
|
|
50
|
+
return envelope if fetch_policy.nil?
|
|
51
|
+
|
|
52
|
+
policy = fetch_policy.to_freshness_policy
|
|
53
|
+
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
54
|
+
outcome = orchestrator.execute(policy.decide(verdict), key: key)
|
|
55
|
+
resolve(outcome, envelope)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
59
|
+
# Used by consumers (e.g. uid, Validator) that distinguish absence.
|
|
60
|
+
def get(key, fetch: false)
|
|
61
|
+
call(key, fetch: fetch) ||
|
|
62
|
+
raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Pure read + freshness verdict; no orchestrator dependency.
|
|
68
|
+
def annotated_envelope(key)
|
|
27
69
|
envelope = read_raw_envelope(key)
|
|
28
70
|
return nil if envelope.nil?
|
|
29
71
|
|
|
30
|
-
|
|
31
|
-
fetch_policy = policy_set.fetch
|
|
72
|
+
fetch_policy = fetch_policy_for(key)
|
|
32
73
|
return annotate_fresh(envelope) if fetch_policy.nil?
|
|
33
74
|
|
|
34
|
-
policy
|
|
75
|
+
policy = fetch_policy.to_freshness_policy
|
|
35
76
|
verdict = @evaluator.call(policy, envelope, now: @call.now)
|
|
36
|
-
|
|
37
77
|
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
38
|
-
stale: verdict.stale?,
|
|
39
|
-
reason: verdict.reason,
|
|
40
|
-
fetching: false,
|
|
78
|
+
stale: verdict.stale?, reason: verdict.reason, fetching: false,
|
|
41
79
|
))
|
|
42
80
|
end
|
|
43
81
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# from emptiness.
|
|
47
|
-
def get(key)
|
|
48
|
-
call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
82
|
+
def fetch_policy_for(key)
|
|
83
|
+
@manifest.rules.for(key).fetch
|
|
49
84
|
end
|
|
50
85
|
|
|
51
|
-
|
|
86
|
+
def resolve(outcome, envelope)
|
|
87
|
+
case outcome
|
|
88
|
+
when Textus::Domain::Outcome::Skipped
|
|
89
|
+
envelope
|
|
90
|
+
when Textus::Domain::Outcome::Fetched
|
|
91
|
+
outcome.envelope.with(
|
|
92
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
|
|
93
|
+
)
|
|
94
|
+
when Textus::Domain::Outcome::Detached
|
|
95
|
+
envelope.with(freshness: envelope.freshness.with(fetching: true))
|
|
96
|
+
when Textus::Domain::Outcome::Failed
|
|
97
|
+
envelope.with(freshness: envelope.freshness.with(fetch_error: outcome.error.message))
|
|
98
|
+
else raise "unexpected fetch outcome: #{outcome.class}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
52
101
|
|
|
53
102
|
def read_raw_envelope(key)
|
|
54
103
|
res = @manifest.resolver.resolve(key)
|
|
@@ -70,6 +119,22 @@ module Textus
|
|
|
70
119
|
stale: false, reason: nil, fetching: false,
|
|
71
120
|
))
|
|
72
121
|
end
|
|
122
|
+
|
|
123
|
+
def orchestrator
|
|
124
|
+
@orchestrator ||= build_orchestrator
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def build_orchestrator
|
|
128
|
+
worker = Textus::Write::FetchWorker.new(container: @container, call: @call)
|
|
129
|
+
Textus::Write::FetchOrchestrator.new(
|
|
130
|
+
worker: worker, store_root: @container.root, events: @container.events,
|
|
131
|
+
hook_context: hook_context
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def hook_context
|
|
136
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
137
|
+
end
|
|
73
138
|
end
|
|
74
139
|
end
|
|
75
140
|
end
|
data/lib/textus/read/list.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Textus
|
|
|
8
8
|
surfaces :cli, :ruby, :mcp
|
|
9
9
|
arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
|
|
10
10
|
arg :zone, String, description: "restrict to one zone by name (see `boot` zones); combine with prefix to narrow further"
|
|
11
|
+
view(:cli) { |rows| { "entries" => rows } }
|
|
11
12
|
|
|
12
13
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
13
14
|
@manifest = container.manifest
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Published
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :published
|
|
7
|
+
summary "List all entries that declare a publish_to target."
|
|
8
|
+
surfaces :cli, :ruby
|
|
9
|
+
cli "published"
|
|
10
|
+
|
|
4
11
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
12
|
@manifest = container.manifest
|
|
6
13
|
end
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Textus
|
|
|
12
12
|
verb :pulse
|
|
13
13
|
summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
|
|
14
14
|
surfaces :cli, :ruby, :mcp
|
|
15
|
+
around :cursor
|
|
15
16
|
arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
|
|
16
17
|
|
|
17
18
|
def initialize(container:, call:)
|
data/lib/textus/read/rdeps.rb
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Rdeps
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :rdeps
|
|
7
|
+
summary "List the derived entries that depend on a key (reverse deps / impact set)."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true,
|
|
10
|
+
description: "dotted key whose dependents (what would be stranded if it moved) you want"
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
|
7
15
|
|
|
8
16
|
def call(key)
|
|
17
|
+
{ "key" => key, "rdeps" => dependents_of(key) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def dependents_of(key)
|
|
9
23
|
@manifest.data.entries.each_with_object([]) do |e, acc|
|
|
10
24
|
next unless e.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
25
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Effective rules for a key, at two depths (ADR 0059). Lean by default —
|
|
4
|
+
# `{ fetch, guard }`, the agent-cheap read that was the `rules` verb. With
|
|
5
|
+
# `detail: true` it returns the verbose explanation — every matching policy
|
|
6
|
+
# block plus the per-transition guard predicate names — that was
|
|
7
|
+
# `policy_explain`. One verb, one name across CLI/MCP/method; the audience
|
|
8
|
+
# split is a parameter, not two tools.
|
|
9
|
+
class RuleExplain
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :rule_explain
|
|
13
|
+
summary "Effective rules for a key. Lean {fetch, guard} by default; detail: true adds matched blocks + guard predicates."
|
|
14
|
+
surfaces :cli, :ruby, :mcp
|
|
15
|
+
cli "rule explain"
|
|
16
|
+
arg :key, String, required: true, positional: true,
|
|
17
|
+
description: "dotted key whose effective rules you want (fetch ttl/action, write guard, ...)"
|
|
18
|
+
arg :detail, :boolean,
|
|
19
|
+
description: "defaults false: lean {fetch, guard}. detail: true adds matched blocks + guard predicates per transition."
|
|
20
|
+
view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
|
|
21
|
+
|
|
22
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
+
@manifest = container.manifest
|
|
24
|
+
@schemas = container.schemas
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(key, detail: false)
|
|
28
|
+
detail ? explain(key) : effective(key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Lean: the effective winners only (formerly Read::Rules / the `rules` verb).
|
|
34
|
+
def effective(key)
|
|
35
|
+
set = @manifest.rules.for(key)
|
|
36
|
+
{
|
|
37
|
+
"fetch" => set.fetch && {
|
|
38
|
+
"ttl_seconds" => set.fetch.ttl_seconds,
|
|
39
|
+
"on_stale" => set.fetch.on_stale,
|
|
40
|
+
"sync_budget_ms" => set.fetch.sync_budget_ms,
|
|
41
|
+
"fetch_timeout_seconds" => set.fetch.fetch_timeout_seconds,
|
|
42
|
+
},
|
|
43
|
+
"guard" => set.guard,
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Verbose: every matching block, per-slot effective value, and the
|
|
48
|
+
# effective guard predicate names for each write transition (formerly
|
|
49
|
+
# Read::PolicyExplain, ADR 0031).
|
|
50
|
+
def explain(key)
|
|
51
|
+
matching = @manifest.rules.explain(key)
|
|
52
|
+
winners = @manifest.rules.for(key)
|
|
53
|
+
factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
key: key,
|
|
57
|
+
matched_blocks: matching.map do |b|
|
|
58
|
+
{
|
|
59
|
+
match: b.match,
|
|
60
|
+
fetch: !b.fetch.nil?,
|
|
61
|
+
handler_allowlist: !b.handler_allowlist.nil?,
|
|
62
|
+
guard: !b.guard.nil?,
|
|
63
|
+
retention: !b.retention.nil?,
|
|
64
|
+
}
|
|
65
|
+
end,
|
|
66
|
+
effective: {
|
|
67
|
+
fetch: winners.fetch && {
|
|
68
|
+
ttl_seconds: winners.fetch.ttl_seconds,
|
|
69
|
+
on_stale: winners.fetch.on_stale,
|
|
70
|
+
},
|
|
71
|
+
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
72
|
+
retention: winners.retention && {
|
|
73
|
+
expire_after: winners.retention.expire_after,
|
|
74
|
+
archive_after: winners.retention.archive_after,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
|
|
78
|
+
[transition, factory.for(transition, key).predicates.map(&:name)]
|
|
79
|
+
end,
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Enumerate every declared rule block in the manifest, in order. This is
|
|
4
|
+
# the whole-manifest view; `rule_explain` is the for-key view. Extracted
|
|
5
|
+
# from the CLI verb so the rule family is fully use-case-backed (ADR 0059);
|
|
6
|
+
# CLI-only (no MCP contract) — an agent reasons per-key via rule_explain.
|
|
7
|
+
class RuleList
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :rule_list
|
|
11
|
+
summary "List every rule block in the manifest."
|
|
12
|
+
surfaces :cli, :ruby
|
|
13
|
+
cli "rule list"
|
|
14
|
+
view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
|
|
15
|
+
|
|
16
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
+
@manifest = container.manifest
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
@manifest.rules.blocks.map do |b|
|
|
22
|
+
row = { "match" => b.match }
|
|
23
|
+
if b.fetch
|
|
24
|
+
row["fetch"] = {
|
|
25
|
+
"ttl_seconds" => b.fetch.ttl_seconds,
|
|
26
|
+
"on_stale" => b.fetch.on_stale,
|
|
27
|
+
"sync_budget_ms" => b.fetch.sync_budget_ms,
|
|
28
|
+
"fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
32
|
+
row["guard"] = b.guard if b.guard
|
|
33
|
+
row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
|
|
34
|
+
row
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -3,9 +3,10 @@ module Textus
|
|
|
3
3
|
class SchemaEnvelope
|
|
4
4
|
extend Textus::Contract::DSL
|
|
5
5
|
|
|
6
|
-
verb :
|
|
6
|
+
verb :schema_show
|
|
7
7
|
summary "Return the schema (field shape) for an entry's family, by key."
|
|
8
|
-
surfaces :ruby, :mcp
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
cli "schema show"
|
|
9
10
|
arg :key, String, required: true, positional: true,
|
|
10
11
|
description: "any key in the family whose schema you want; returns required/optional fields and their types"
|
|
11
12
|
|
data/lib/textus/read/uid.rb
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Uid
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :uid
|
|
7
|
+
summary "Return the stable UID of an entry without reading its body."
|
|
8
|
+
surfaces :cli, :ruby
|
|
9
|
+
cli "key uid"
|
|
10
|
+
arg :key, String, required: true, positional: true, description: "entry key"
|
|
11
|
+
view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
|
|
12
|
+
|
|
4
13
|
def initialize(container:, call:)
|
|
5
14
|
@container = container
|
|
6
15
|
@call = call
|
data/lib/textus/read/where.rb
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Where
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :where
|
|
7
|
+
summary "Resolve a key to its zone, owner, and path without reading the body."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true,
|
|
10
|
+
description: "dotted key to locate (returns zone, owner, path; does not read content)"
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
data/lib/textus/role_scope.rb
CHANGED
|
@@ -36,14 +36,42 @@ module Textus
|
|
|
36
36
|
self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Single bind + invoke site for every surface. `inputs` is the uniform
|
|
40
|
+
# by-name hash (the binder's currency). MCP/CLI build it from their raw
|
|
41
|
+
# transport shape and call this directly; the per-verb Ruby methods below
|
|
42
|
+
# normalize positional+keyword Ruby args into `inputs` and delegate here.
|
|
43
|
+
def dispatch_bound(verb, inputs, session: nil)
|
|
44
|
+
klass = Textus::Dispatcher::VERBS[verb]
|
|
45
|
+
spec = (klass.contract if klass.respond_to?(:contract?) && klass.contract?)
|
|
46
|
+
|
|
47
|
+
invoke = lambda do |effective_inputs|
|
|
48
|
+
args, kwargs =
|
|
49
|
+
if spec
|
|
50
|
+
Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
|
|
51
|
+
else
|
|
52
|
+
[[], effective_inputs]
|
|
53
|
+
end
|
|
54
|
+
call_value = Textus::Call.build(role: @role, correlation_id: @correlation_id, dry_run: @dry_run)
|
|
55
|
+
Textus::Dispatcher.invoke(verb, container: @container, call: call_value, args: args, kwargs: kwargs)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if spec&.around
|
|
59
|
+
Textus::Contract::Around.with(spec.around, scope: self, inputs: inputs, session: session, &invoke)
|
|
60
|
+
else
|
|
61
|
+
invoke.call(inputs)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
39
65
|
Textus::Dispatcher::VERBS.each_key do |verb|
|
|
40
66
|
define_method(verb) do |*args, **kwargs|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
klass = Textus::Dispatcher::VERBS[verb]
|
|
68
|
+
inputs =
|
|
69
|
+
if klass.respond_to?(:contract?) && klass.contract?
|
|
70
|
+
Textus::Contract::Binder.inputs_from_ordered(klass.contract, args, kwargs)
|
|
71
|
+
else
|
|
72
|
+
kwargs
|
|
73
|
+
end
|
|
74
|
+
dispatch_bound(verb, inputs)
|
|
47
75
|
end
|
|
48
76
|
end
|
|
49
77
|
end
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
module Tools
|
|
7
7
|
# textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
|
|
8
8
|
def self.init(store, name:, from:)
|
|
9
|
-
env = store
|
|
9
|
+
env = pure_get(store, Textus::Role::DEFAULT, from)
|
|
10
10
|
meta = env.meta
|
|
11
11
|
schema = {
|
|
12
12
|
"name" => name,
|
|
@@ -25,7 +25,7 @@ module Textus
|
|
|
25
25
|
schema = load_schema(store, name)
|
|
26
26
|
drift = []
|
|
27
27
|
store.manifest.resolver.enumerate.each do |row|
|
|
28
|
-
env = store
|
|
28
|
+
env = pure_get(store, Textus::Role::DEFAULT, row[:key])
|
|
29
29
|
begin
|
|
30
30
|
schema.validate!(env.meta)
|
|
31
31
|
rescue SchemaViolation => e
|
|
@@ -53,7 +53,7 @@ module Textus
|
|
|
53
53
|
ops = store.as(authority)
|
|
54
54
|
touched = []
|
|
55
55
|
store.manifest.resolver.enumerate.each do |row|
|
|
56
|
-
env =
|
|
56
|
+
env = pure_get(store, authority, row[:key])
|
|
57
57
|
meta = env.meta.dup
|
|
58
58
|
changed = false
|
|
59
59
|
renames.each do |old, new|
|
|
@@ -81,6 +81,15 @@ module Textus
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# Orchestrator-free read: schema tooling must never trigger a fetch
|
|
85
|
+
# while inspecting/migrating entries (ADR 0062).
|
|
86
|
+
def self.pure_get(store, role, key)
|
|
87
|
+
Textus::Read::Get.new(
|
|
88
|
+
container: store.as(role).container,
|
|
89
|
+
call: Textus::Call.build(role: role),
|
|
90
|
+
).call(key)
|
|
91
|
+
end
|
|
92
|
+
|
|
84
93
|
def self.load_schema(store, name)
|
|
85
94
|
store.schemas.fetch(name)
|
|
86
95
|
rescue IoError
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/accept.rb
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Write
|
|
3
3
|
class Accept
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :accept
|
|
7
|
+
summary "apply a queued proposal to its target zone; requires the author capability"
|
|
8
|
+
surfaces :cli, :ruby
|
|
9
|
+
cli "accept"
|
|
10
|
+
arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call:)
|
|
5
13
|
@container = container
|
|
6
14
|
@call = call
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Write
|
|
3
|
-
# Single-pass
|
|
4
|
-
# entry's `publish_via` method
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
3
|
+
# Single-pass build use case (the verb `build`, ADR 0061): dispatches
|
|
4
|
+
# polymorphically to each entry's `publish_via` method — the copy-out step
|
|
5
|
+
# (`publish` is the output-destination concept the verb drives, not the verb).
|
|
6
|
+
# Derived entries materialize their body via Materializer; Nested entries
|
|
7
|
+
# mirror their subtree via publish_tree; Leaf and Intake entries copy their
|
|
8
|
+
# stored body to publish_to targets. The Build layer owns wiring (context,
|
|
9
|
+
# accumulation) but not per-kind logic.
|
|
9
10
|
#
|
|
10
11
|
# Return shape: { "protocol", "built", "published_leaves" }
|
|
11
|
-
class
|
|
12
|
+
class Build
|
|
13
|
+
extend Textus::Contract::DSL
|
|
14
|
+
|
|
15
|
+
verb :build
|
|
16
|
+
summary "materialize derived entries; publish_to and publish_tree fan out copies"
|
|
17
|
+
surfaces :cli, :ruby
|
|
18
|
+
cli "build"
|
|
19
|
+
arg :prefix, String, required: false, description: "limit the build to keys under this prefix"
|
|
20
|
+
|
|
12
21
|
def initialize(container:, call:)
|
|
13
22
|
@container = container
|
|
14
23
|
@call = call
|