textus 0.47.1 → 0.50.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/CHANGELOG.md +51 -0
- data/README.md +9 -7
- data/SPEC.md +44 -69
- data/docs/reference/conventions.md +13 -12
- data/lib/textus/boot.rb +47 -32
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/cli/runner.rb +5 -4
- data/lib/textus/cli/verb/boot.rb +2 -1
- data/lib/textus/cli.rb +0 -1
- data/lib/textus/dispatcher.rb +3 -8
- data/lib/textus/doctor/check/generator_drift.rb +28 -0
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
- data/lib/textus/doctor.rb +2 -0
- data/lib/textus/domain/lifecycle.rb +83 -0
- data/lib/textus/domain/policy/base_guards.rb +2 -2
- data/lib/textus/domain/policy/lifecycle.rb +35 -0
- data/lib/textus/domain/staleness.rb +6 -3
- data/lib/textus/envelope/io/writer.rb +2 -2
- data/lib/textus/hooks/context.rb +1 -1
- data/lib/textus/init.rb +4 -4
- data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
- data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
- data/lib/textus/maintenance/tend.rb +110 -0
- data/lib/textus/manifest/entry/base.rb +1 -0
- data/lib/textus/manifest/entry/derived.rb +4 -2
- data/lib/textus/manifest/rules.rb +11 -23
- data/lib/textus/manifest/schema.rb +4 -19
- data/lib/textus/mcp/server.rb +9 -2
- data/lib/textus/ports/audit_log.rb +1 -1
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +5 -1
- data/lib/textus/read/boot.rb +4 -2
- data/lib/textus/read/freshness.rb +37 -26
- data/lib/textus/read/get.rb +47 -32
- data/lib/textus/read/pulse.rb +1 -1
- data/lib/textus/read/rule_explain.rb +10 -16
- data/lib/textus/read/rule_list.rb +5 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +8 -12
- data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
- data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
- data/lib/textus/write/reject.rb +1 -1
- metadata +8 -15
- data/lib/textus/cli/group/fetch.rb +0 -20
- data/lib/textus/cli/verb/fetch.rb +0 -14
- data/lib/textus/cli/verb/fetch_all.rb +0 -20
- data/lib/textus/domain/policy/fetch.rb +0 -37
- data/lib/textus/domain/policy/retention.rb +0 -26
- data/lib/textus/domain/retention.rb +0 -44
- data/lib/textus/domain/staleness/intake_check.rb +0 -54
- data/lib/textus/maintenance/migrate.rb +0 -65
- data/lib/textus/read/retainable.rb +0 -17
- data/lib/textus/read/stale.rb +0 -17
- data/lib/textus/write/fetch_all.rb +0 -53
- data/lib/textus/write/retention_sweep.rb +0 -64
|
@@ -42,6 +42,7 @@ module Textus
|
|
|
42
42
|
# without `respond_to?` guards.
|
|
43
43
|
def template = nil
|
|
44
44
|
def inject_boot = false # rubocop:disable Naming/PredicateMethod
|
|
45
|
+
def provenance = true # rubocop:disable Naming/PredicateMethod
|
|
45
46
|
def events = {}
|
|
46
47
|
def publish_tree = nil
|
|
47
48
|
def ignore = []
|
|
@@ -5,13 +5,14 @@ module Textus
|
|
|
5
5
|
Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
6
|
External = ::Data.define(:sources, :command)
|
|
7
7
|
|
|
8
|
-
attr_reader :source, :template, :inject_boot, :events
|
|
8
|
+
attr_reader :source, :template, :inject_boot, :provenance, :events
|
|
9
9
|
|
|
10
|
-
def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
|
|
10
|
+
def initialize(source:, template: nil, inject_boot: false, provenance: true, events: {}, **rest)
|
|
11
11
|
super(**rest)
|
|
12
12
|
@source = source
|
|
13
13
|
@template = template
|
|
14
14
|
@inject_boot = inject_boot
|
|
15
|
+
@provenance = provenance
|
|
15
16
|
@events = events || {}
|
|
16
17
|
end
|
|
17
18
|
|
|
@@ -53,6 +54,7 @@ module Textus
|
|
|
53
54
|
source: source,
|
|
54
55
|
template: raw["template"],
|
|
55
56
|
inject_boot: raw["inject_boot"] == true,
|
|
57
|
+
provenance: raw.fetch("provenance", true) != false,
|
|
56
58
|
events: raw["events"] || {},
|
|
57
59
|
**common,
|
|
58
60
|
)
|
|
@@ -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(:handler_allowlist, :guard, :lifecycle)
|
|
5
|
+
EMPTY_SET = RuleSet.new(handler_allowlist: nil, guard: nil, lifecycle: nil)
|
|
6
6
|
|
|
7
7
|
def self.parse(raw)
|
|
8
8
|
new(Array(raw).map { |b| Block.new(b) })
|
|
@@ -15,17 +15,16 @@ module Textus
|
|
|
15
15
|
attr_reader :blocks
|
|
16
16
|
|
|
17
17
|
def for(key)
|
|
18
|
-
slots = {
|
|
18
|
+
slots = { handler_allowlist: [], guard: [], lifecycle: [] }
|
|
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
|
-
fetch: pick(slots[:fetch], :fetch, key),
|
|
26
25
|
handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
|
|
27
26
|
guard: pick(slots[:guard], :guard, key),
|
|
28
|
-
|
|
27
|
+
lifecycle: pick(slots[:lifecycle], :lifecycle, key),
|
|
29
28
|
)
|
|
30
29
|
end
|
|
31
30
|
|
|
@@ -44,29 +43,17 @@ module Textus
|
|
|
44
43
|
end
|
|
45
44
|
|
|
46
45
|
class Block
|
|
47
|
-
attr_reader :match, :
|
|
46
|
+
attr_reader :match, :handler_allowlist, :guard, :lifecycle
|
|
48
47
|
|
|
49
48
|
def initialize(raw)
|
|
50
49
|
@match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
|
|
51
|
-
@fetch = parse_fetch(raw["fetch"])
|
|
52
50
|
@handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
|
|
53
51
|
@guard = parse_guard(raw["guard"])
|
|
54
|
-
@
|
|
52
|
+
@lifecycle = parse_lifecycle(raw["lifecycle"])
|
|
55
53
|
end
|
|
56
54
|
|
|
57
55
|
private
|
|
58
56
|
|
|
59
|
-
def parse_fetch(h)
|
|
60
|
-
return nil if h.nil?
|
|
61
|
-
|
|
62
|
-
Textus::Domain::Policy::Fetch.new(
|
|
63
|
-
ttl: h["ttl"],
|
|
64
|
-
on_stale: h["on_stale"] || "warn",
|
|
65
|
-
sync_budget_ms: h["sync_budget_ms"],
|
|
66
|
-
fetch_timeout_seconds: h["fetch_timeout_seconds"],
|
|
67
|
-
)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
57
|
def parse_handler_allowlist(arr)
|
|
71
58
|
return nil if arr.nil?
|
|
72
59
|
|
|
@@ -83,12 +70,13 @@ module Textus
|
|
|
83
70
|
h
|
|
84
71
|
end
|
|
85
72
|
|
|
86
|
-
def
|
|
73
|
+
def parse_lifecycle(h)
|
|
87
74
|
return nil if h.nil?
|
|
88
75
|
|
|
89
|
-
Textus::Domain::Policy::
|
|
90
|
-
|
|
91
|
-
|
|
76
|
+
Textus::Domain::Policy::Lifecycle.new(
|
|
77
|
+
ttl: h["ttl"],
|
|
78
|
+
on_expire: h["on_expire"],
|
|
79
|
+
budget_ms: h["budget_ms"],
|
|
92
80
|
)
|
|
93
81
|
end
|
|
94
82
|
end
|
|
@@ -25,17 +25,15 @@ module Textus
|
|
|
25
25
|
ENTRY_KEYS = %w[
|
|
26
26
|
key path zone kind schema owner nested format
|
|
27
27
|
compute template publish
|
|
28
|
-
intake events inject_boot ignore tracked
|
|
28
|
+
intake events inject_boot provenance ignore tracked
|
|
29
29
|
].freeze
|
|
30
30
|
# ADR 0052: the typed publish block — `publish: { to: [...] }` (file
|
|
31
31
|
# fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
|
|
32
32
|
PUBLISH_KEYS = %w[to tree].freeze
|
|
33
33
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
34
34
|
INTAKE_KEYS = %w[handler config].freeze
|
|
35
|
-
RULE_KEYS = %w[match
|
|
36
|
-
|
|
37
|
-
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
38
|
-
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
35
|
+
RULE_KEYS = %w[match intake_handler_allowlist guard lifecycle].freeze
|
|
36
|
+
LIFECYCLE_KEYS = %w[ttl on_expire budget_ms].freeze
|
|
39
37
|
AUDIT_KEYS = %w[max_size keep].freeze
|
|
40
38
|
|
|
41
39
|
# Syntactic shape of an `owner:` subject token (the `patrick` in
|
|
@@ -136,11 +134,7 @@ module Textus
|
|
|
136
134
|
Array(rules).each_with_index do |r, i|
|
|
137
135
|
path = "$.rules[#{i}]"
|
|
138
136
|
walk(r, RULE_KEYS, path)
|
|
139
|
-
if r["
|
|
140
|
-
walk(r["fetch"], FETCH_KEYS, "#{path}.fetch")
|
|
141
|
-
validate_fetch_timeout!(r["fetch"]["fetch_timeout_seconds"], "#{path}.fetch.fetch_timeout_seconds")
|
|
142
|
-
end
|
|
143
|
-
walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
|
|
137
|
+
walk(r["lifecycle"], LIFECYCLE_KEYS, "#{path}.lifecycle") if r["lifecycle"].is_a?(Hash)
|
|
144
138
|
end
|
|
145
139
|
end
|
|
146
140
|
|
|
@@ -217,15 +211,6 @@ module Textus
|
|
|
217
211
|
OWNER_SUBJECT_PATTERN.match?(subject)
|
|
218
212
|
end
|
|
219
213
|
|
|
220
|
-
def self.validate_fetch_timeout!(value, path)
|
|
221
|
-
return if value.nil?
|
|
222
|
-
return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
|
|
223
|
-
|
|
224
|
-
raise BadManifest.new(
|
|
225
|
-
"fetch_timeout_seconds at '#{path}' must be a positive integer ≤ #{FETCH_TIMEOUT_SECONDS_CEILING} (got #{value.inspect})",
|
|
226
|
-
)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
214
|
def self.walk(hash, allowed, path)
|
|
230
215
|
return unless hash.is_a?(Hash)
|
|
231
216
|
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -89,12 +89,19 @@ module Textus
|
|
|
89
89
|
return
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
@session.check_etag!(contract_etag)
|
|
93
|
-
|
|
94
92
|
name = params["name"]
|
|
95
93
|
args = params["arguments"] || {}
|
|
94
|
+
|
|
95
|
+
# ADR 0083: the contract-drift guard gates mutating verbs — every MCP
|
|
96
|
+
# verb that is NOT a pure read (Write:: + the destructive Maintenance::
|
|
97
|
+
# verbs tend/zone_mv/key_*_prefix). Reads and boot bypass it (a stale
|
|
98
|
+
# read returns on-disk truth; boot re-orients). Keying on read_verbs
|
|
99
|
+
# (not write_verbs) keeps the destructive Maintenance:: verbs gated.
|
|
100
|
+
@session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
|
|
101
|
+
|
|
96
102
|
result = Catalog.call(name, session: @session, store: @store, args: args)
|
|
97
103
|
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
|
|
104
|
+
@session = @session.with(contract_etag: contract_etag) if name == "boot"
|
|
98
105
|
|
|
99
106
|
emit_result(rid, {
|
|
100
107
|
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
|
@@ -23,7 +23,7 @@ module Textus
|
|
|
23
23
|
parsed = parse_row(line.chomp)
|
|
24
24
|
next unless parsed
|
|
25
25
|
next unless parsed["key"] == key
|
|
26
|
-
next unless %w[put delete].include?(parsed["verb"])
|
|
26
|
+
next unless %w[put delete key_delete].include?(parsed["verb"])
|
|
27
27
|
|
|
28
28
|
last_role = parsed["role"]
|
|
29
29
|
end
|
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
# rescue and the failure is a bus-internal concern, not a domain
|
|
11
11
|
# event subscribers should be able to filter by key glob).
|
|
12
12
|
#
|
|
13
|
-
# Lifecycle audit rows for verb: "put" / "
|
|
13
|
+
# Lifecycle audit rows for verb: "put" / "key_delete" / "key_mv" are written
|
|
14
14
|
# by Envelope::IO::Writer directly (it owns the
|
|
15
15
|
# audit-append-as-final-step invariant); this subscriber covers the
|
|
16
16
|
# hook-failure case the writer never sees.
|
|
@@ -31,7 +31,11 @@ module Textus
|
|
|
31
31
|
# exists). Config-time detection is doctor's job (ADR 0044 Q2).
|
|
32
32
|
role = acting_role(store)
|
|
33
33
|
exit(0) unless role
|
|
34
|
-
|
|
34
|
+
# FetchWorker is the internal executor since the public `fetch`
|
|
35
|
+
# verb was collapsed (ADR 0079); drive it directly.
|
|
36
|
+
Textus::Write::FetchWorker.new(
|
|
37
|
+
container: store.container, call: Textus::Call.build(role: role),
|
|
38
|
+
).run(key)
|
|
35
39
|
rescue StandardError
|
|
36
40
|
# Already logged via :fetch_failed; exit cleanly.
|
|
37
41
|
ensure
|
data/lib/textus/read/boot.rb
CHANGED
|
@@ -10,14 +10,16 @@ module Textus
|
|
|
10
10
|
verb :boot
|
|
11
11
|
summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
|
|
12
12
|
surfaces :cli, :mcp
|
|
13
|
+
arg :lean, :boolean,
|
|
14
|
+
description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
|
|
13
15
|
|
|
14
16
|
def initialize(container:, call:)
|
|
15
17
|
@container = container
|
|
16
18
|
@call = call
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def call
|
|
20
|
-
Textus::Boot.build(container: @container)
|
|
21
|
+
def call(lean: false)
|
|
22
|
+
Textus::Boot.build(container: @container, lean: lean)
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
25
|
end
|
|
@@ -2,28 +2,30 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Read
|
|
5
|
-
# Per-entry
|
|
6
|
-
# consults `
|
|
7
|
-
#
|
|
8
|
-
# :no_policy.
|
|
5
|
+
# Per-entry lifecycle scan (ADR 0079, 0085). Walks every entry declared in
|
|
6
|
+
# the manifest, consults `rules.for(key)` for a `lifecycle:` policy, and
|
|
7
|
+
# reports the unified verdict. Status is one of :fresh, :expired, or
|
|
8
|
+
# :no_policy; the row also carries the policy's :action (on_expire).
|
|
9
|
+
#
|
|
10
|
+
# ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
|
|
11
|
+
# surface. This is now a Ruby-only internal scan (empty `surfaces`, the
|
|
12
|
+
# honest home reserved by ADR 0073) consumed by `pulse` (which derives
|
|
13
|
+
# `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
|
|
14
|
+
# into per-entry lifecycle detail via `get` (last_fetched_at) + `rule_explain`
|
|
15
|
+
# (the ttl / on_expire policy) instead of a dedicated verb.
|
|
9
16
|
class Freshness
|
|
10
17
|
extend Textus::Contract::DSL
|
|
11
18
|
|
|
12
19
|
verb :freshness
|
|
13
|
-
summary "
|
|
14
|
-
surfaces :cli
|
|
15
|
-
cli "freshness"
|
|
20
|
+
summary "Internal per-entry lifecycle scan (status, age, ttl, on_expire); backs pulse + hook context. No public surface (ADR 0085)."
|
|
16
21
|
arg :prefix, String, required: false, description: "filter to keys with this prefix"
|
|
17
22
|
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
18
|
-
view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
|
|
19
23
|
|
|
20
|
-
def initialize(container:, call
|
|
24
|
+
def initialize(container:, call:)
|
|
21
25
|
@container = container
|
|
22
26
|
@call = call
|
|
23
27
|
@manifest = container.manifest
|
|
24
28
|
@file_store = container.file_store
|
|
25
|
-
@evaluator = evaluator
|
|
26
|
-
@cache = {}
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# Returns the soonest `next_due_at` across all entries with a fetch
|
|
@@ -52,29 +54,38 @@ module Textus
|
|
|
52
54
|
private
|
|
53
55
|
|
|
54
56
|
def row_for(mentry)
|
|
55
|
-
|
|
56
|
-
fetch = set.fetch
|
|
57
|
+
policy = lifecycle_for(mentry.key)
|
|
57
58
|
envelope = safe_get(mentry.key)
|
|
58
59
|
last = envelope&.meta&.dig("last_fetched_at")
|
|
59
60
|
|
|
60
|
-
return base_row(mentry, last).merge(status: :no_policy) if
|
|
61
|
-
|
|
62
|
-
fp = fetch.to_freshness_policy
|
|
63
|
-
cache_key = [mentry.key, last]
|
|
64
|
-
verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
|
|
65
|
-
status = if verdict.fresh? then :fresh
|
|
66
|
-
elsif last.nil? then :never_fetched
|
|
67
|
-
else :stale
|
|
68
|
-
end
|
|
61
|
+
return base_row(mentry, last).merge(status: :no_policy) if policy.nil?
|
|
69
62
|
|
|
63
|
+
expired, reason = Textus::Domain::Lifecycle.verdict(
|
|
64
|
+
policy: policy,
|
|
65
|
+
last_fetched_at: last,
|
|
66
|
+
mtime: mtime_for(mentry.key),
|
|
67
|
+
now: @call.now,
|
|
68
|
+
)
|
|
70
69
|
base_row(mentry, last).merge(
|
|
71
|
-
ttl_seconds:
|
|
72
|
-
|
|
73
|
-
status:
|
|
74
|
-
|
|
70
|
+
ttl_seconds: policy.ttl_seconds,
|
|
71
|
+
action: policy.on_expire,
|
|
72
|
+
status: expired ? :expired : :fresh,
|
|
73
|
+
reason: reason,
|
|
74
|
+
next_due_at: next_due(last, policy.ttl_seconds),
|
|
75
75
|
)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
def lifecycle_for(key)
|
|
79
|
+
@manifest.rules.for(key).lifecycle
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def mtime_for(key)
|
|
83
|
+
path = @manifest.resolver.resolve(key).path
|
|
84
|
+
@file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
|
|
85
|
+
rescue Textus::Error
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
78
89
|
def base_row(mentry, last)
|
|
79
90
|
{
|
|
80
91
|
key: mentry.key,
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -2,42 +2,39 @@ module Textus
|
|
|
2
2
|
module Read
|
|
3
3
|
# The one read path. `fetch:` controls behavior:
|
|
4
4
|
# fetch: false (default) — pure read: the on-disk envelope annotated with
|
|
5
|
-
# a freshness verdict. NEVER builds the orchestrator
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# result when the key has no rule).
|
|
5
|
+
# a lifecycle freshness verdict. NEVER builds the orchestrator and NEVER
|
|
6
|
+
# mutates. Safe for direct callers (accept/reject/publish, materializer,
|
|
7
|
+
# uid, validators, hooks).
|
|
8
|
+
# fetch: true — read-through: after a stale verdict on a `refresh` policy,
|
|
9
|
+
# hands off to the fetch orchestrator. A read NEVER performs a
|
|
10
|
+
# destructive action (drop/archive) — those belong to the `tend` sweep
|
|
11
|
+
# (ADR 0079).
|
|
13
12
|
#
|
|
14
|
-
#
|
|
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.
|
|
13
|
+
# Lifecycle policy comes from the unified `lifecycle:` rule slot (ADR 0079).
|
|
18
14
|
class Get
|
|
19
15
|
extend Textus::Contract::DSL
|
|
20
16
|
|
|
21
17
|
verb :get
|
|
22
|
-
summary "Read one entry. Read-through by default —
|
|
23
|
-
"the entry's
|
|
24
|
-
"has no rule. Pass fetch:false for a
|
|
25
|
-
"read. Returns the envelope (uid, etag,
|
|
18
|
+
summary "Read one entry. Read-through by default — refreshes on stale per " \
|
|
19
|
+
"the entry's lifecycle rule (on_expire: refresh), degrading to a " \
|
|
20
|
+
"pure read when the key has no rule. Pass fetch:false for a " \
|
|
21
|
+
"guaranteed pure on-disk read. Returns the envelope (uid, etag, " \
|
|
22
|
+
"_meta, body, freshness)."
|
|
26
23
|
surfaces :cli, :mcp
|
|
27
24
|
arg :key, String, required: true, positional: true,
|
|
28
25
|
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
29
26
|
arg :fetch, :boolean, default: true,
|
|
30
|
-
description: "read-through (
|
|
31
|
-
"entry's
|
|
27
|
+
description: "read-through (refresh on stale per the " \
|
|
28
|
+
"entry's lifecycle rule) when true, the default; " \
|
|
32
29
|
"false returns the on-disk envelope without ever fetching"
|
|
33
30
|
view { |v, _i| v.to_h_for_wire }
|
|
34
31
|
|
|
35
|
-
def initialize(container:, call:,
|
|
32
|
+
def initialize(container:, call:, orchestrator: nil, file_stat: Textus::Ports::Storage::FileStat.new)
|
|
36
33
|
@container = container
|
|
37
34
|
@call = call
|
|
38
35
|
@manifest = container.manifest
|
|
39
36
|
@file_store = container.file_store
|
|
40
|
-
@
|
|
37
|
+
@file_stat = file_stat
|
|
41
38
|
@orchestrator = orchestrator # nil → built lazily on first fetch only
|
|
42
39
|
end
|
|
43
40
|
|
|
@@ -46,12 +43,11 @@ module Textus
|
|
|
46
43
|
return envelope if envelope.nil?
|
|
47
44
|
return envelope unless fetch && envelope.freshness&.stale
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
return envelope
|
|
46
|
+
policy = lifecycle_for(key)
|
|
47
|
+
return envelope unless policy&.on_expire == :refresh # only refresh acts on a read
|
|
51
48
|
|
|
52
|
-
policy = fetch_policy.to_freshness_policy
|
|
53
49
|
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
54
|
-
outcome = orchestrator.execute(policy.decide(verdict), key: key)
|
|
50
|
+
outcome = orchestrator.execute(refresh_policy(policy).decide(verdict), key: key)
|
|
55
51
|
resolve(outcome, envelope)
|
|
56
52
|
end
|
|
57
53
|
|
|
@@ -64,23 +60,42 @@ module Textus
|
|
|
64
60
|
|
|
65
61
|
private
|
|
66
62
|
|
|
67
|
-
# Pure read +
|
|
63
|
+
# Pure read + unified lifecycle verdict; no orchestrator dependency.
|
|
68
64
|
def annotated_envelope(key)
|
|
69
65
|
envelope = read_raw_envelope(key)
|
|
70
66
|
return nil if envelope.nil?
|
|
71
67
|
|
|
72
|
-
|
|
73
|
-
return annotate_fresh(envelope) if
|
|
68
|
+
policy = lifecycle_for(key)
|
|
69
|
+
return annotate_fresh(envelope) if policy.nil?
|
|
74
70
|
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
expired, reason = Textus::Domain::Lifecycle.verdict(
|
|
72
|
+
policy: policy,
|
|
73
|
+
last_fetched_at: envelope.meta&.dig("last_fetched_at"),
|
|
74
|
+
mtime: mtime_for(key),
|
|
75
|
+
now: @call.now,
|
|
76
|
+
)
|
|
77
77
|
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
78
|
-
stale:
|
|
78
|
+
stale: expired, reason: reason, fetching: false,
|
|
79
79
|
))
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
def
|
|
83
|
-
@manifest.rules.for(key).
|
|
82
|
+
def lifecycle_for(key)
|
|
83
|
+
@manifest.rules.for(key).lifecycle
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def refresh_policy(policy)
|
|
87
|
+
Textus::Domain::Freshness::Policy.new(
|
|
88
|
+
ttl_seconds: policy.ttl_seconds,
|
|
89
|
+
on_stale: policy.budget_ms ? :timed_sync : :sync,
|
|
90
|
+
sync_budget_ms: policy.budget_ms,
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def mtime_for(key)
|
|
95
|
+
path = @manifest.resolver.resolve(key).path
|
|
96
|
+
@file_stat.exists?(path) ? @file_stat.mtime(path) : nil
|
|
97
|
+
rescue Textus::Error
|
|
98
|
+
nil
|
|
84
99
|
end
|
|
85
100
|
|
|
86
101
|
def resolve(outcome, envelope)
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Textus
|
|
|
30
30
|
{
|
|
31
31
|
"cursor" => @audit_log.latest_seq,
|
|
32
32
|
"changed" => audit_changes_since(since),
|
|
33
|
-
"stale" => freshness_rows.select { |r| r[:status] == :
|
|
33
|
+
"stale" => freshness_rows.select { |r| r[:status] == :expired }.map { |r| r[:key] },
|
|
34
34
|
"pending_review" => review_keys,
|
|
35
35
|
"doctor" => doctor_summary,
|
|
36
36
|
"contract_etag" => contract_etag,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
# Effective rules for a key, at two depths (ADR 0059). Lean by default —
|
|
4
|
-
# `{
|
|
4
|
+
# `{ lifecycle, guard }`, the agent-cheap read that was the `rules` verb. With
|
|
5
5
|
# `detail: true` it returns the verbose explanation — every matching policy
|
|
6
6
|
# block plus the per-transition guard predicate names — that was
|
|
7
7
|
# `policy_explain`. One verb, one name across CLI/MCP/method; the audience
|
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
extend Textus::Contract::DSL
|
|
11
11
|
|
|
12
12
|
verb :rule_explain
|
|
13
|
-
summary "Effective rules for a key. Lean {
|
|
13
|
+
summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
|
|
14
14
|
surfaces :cli, :mcp
|
|
15
15
|
cli "rule explain"
|
|
16
16
|
arg :key, String, required: true, positional: true,
|
|
@@ -34,11 +34,10 @@ module Textus
|
|
|
34
34
|
def effective(key)
|
|
35
35
|
set = @manifest.rules.for(key)
|
|
36
36
|
{
|
|
37
|
-
"
|
|
38
|
-
"ttl_seconds" => set.
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"fetch_timeout_seconds" => set.fetch.fetch_timeout_seconds,
|
|
37
|
+
"lifecycle" => set.lifecycle && {
|
|
38
|
+
"ttl_seconds" => set.lifecycle.ttl_seconds,
|
|
39
|
+
"on_expire" => set.lifecycle.on_expire,
|
|
40
|
+
"budget_ms" => set.lifecycle.budget_ms,
|
|
42
41
|
},
|
|
43
42
|
"guard" => set.guard,
|
|
44
43
|
}.compact
|
|
@@ -57,22 +56,17 @@ module Textus
|
|
|
57
56
|
matched_blocks: matching.map do |b|
|
|
58
57
|
{
|
|
59
58
|
match: b.match,
|
|
60
|
-
|
|
59
|
+
lifecycle: !b.lifecycle.nil?,
|
|
61
60
|
handler_allowlist: !b.handler_allowlist.nil?,
|
|
62
61
|
guard: !b.guard.nil?,
|
|
63
|
-
retention: !b.retention.nil?,
|
|
64
62
|
}
|
|
65
63
|
end,
|
|
66
64
|
effective: {
|
|
67
|
-
|
|
68
|
-
ttl_seconds: winners.
|
|
69
|
-
|
|
65
|
+
lifecycle: winners.lifecycle && {
|
|
66
|
+
ttl_seconds: winners.lifecycle.ttl_seconds,
|
|
67
|
+
on_expire: winners.lifecycle.on_expire,
|
|
70
68
|
},
|
|
71
69
|
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
70
|
},
|
|
77
71
|
guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
|
|
78
72
|
[transition, factory.for(transition, key).predicates.map(&:name)]
|
|
@@ -20,17 +20,15 @@ module Textus
|
|
|
20
20
|
def call
|
|
21
21
|
@manifest.rules.blocks.map do |b|
|
|
22
22
|
row = { "match" => b.match }
|
|
23
|
-
if b.
|
|
24
|
-
row["
|
|
25
|
-
"ttl_seconds" => b.
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
|
|
23
|
+
if b.lifecycle
|
|
24
|
+
row["lifecycle"] = {
|
|
25
|
+
"ttl_seconds" => b.lifecycle.ttl_seconds,
|
|
26
|
+
"on_expire" => b.lifecycle.on_expire,
|
|
27
|
+
"budget_ms" => b.lifecycle.budget_ms,
|
|
29
28
|
}
|
|
30
29
|
end
|
|
31
30
|
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
32
31
|
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
32
|
row
|
|
35
33
|
end
|
|
36
34
|
end
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/accept.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Textus
|
|
|
52
52
|
|
|
53
53
|
def hook_context = @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
54
54
|
def put_op = @put_op ||= Textus::Write::Put.new(container: @container, call: @call)
|
|
55
|
-
def delete_op = @delete_op ||= Textus::Write::
|
|
55
|
+
def delete_op = @delete_op ||= Textus::Write::KeyDelete.new(container: @container, call: @call)
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
end
|
|
@@ -2,16 +2,10 @@ require "timeout"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Write
|
|
5
|
+
# Internal fetch executor for one quarantine/intake entry. No longer a
|
|
6
|
+
# public verb (ADR 0079 collapsed the `fetch` surface): used by `get`'s
|
|
7
|
+
# orchestrator (read-through refresh) and by the `tend` sweep.
|
|
5
8
|
class FetchWorker
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :fetch
|
|
9
|
-
summary "Run a fetch action for one quarantine entry."
|
|
10
|
-
surfaces :cli, :mcp
|
|
11
|
-
arg :key, String, required: true, positional: true,
|
|
12
|
-
description: "quarantine-zone entry key to refresh using its declared intake action"
|
|
13
|
-
view { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
|
|
14
|
-
|
|
15
9
|
FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
|
|
16
10
|
|
|
17
11
|
def initialize(container:, call:)
|
|
@@ -69,9 +63,11 @@ module Textus
|
|
|
69
63
|
@fetch_events ||= FetchEvents.from(container: @container, call: @call)
|
|
70
64
|
end
|
|
71
65
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
# ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
|
|
67
|
+
# in the fetch:/retention: → lifecycle: collapse; the constant ceiling
|
|
68
|
+
# applies to every intake.
|
|
69
|
+
def fetch_timeout_for(_key)
|
|
70
|
+
FETCH_TIMEOUT_SECONDS
|
|
75
71
|
end
|
|
76
72
|
|
|
77
73
|
def fetch_with_events(key, mentry, remaining)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Write
|
|
3
|
-
class
|
|
3
|
+
class KeyDelete
|
|
4
4
|
extend Textus::Contract::DSL
|
|
5
5
|
|
|
6
|
-
verb :
|
|
6
|
+
verb :key_delete
|
|
7
7
|
summary "Delete one entry by key. Single-key, lower blast radius than " \
|
|
8
8
|
"key_delete_prefix; guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
|
|
9
9
|
surfaces :cli, :mcp
|
|
@@ -25,7 +25,7 @@ module Textus
|
|
|
25
25
|
Textus::Manifest::Data.validate_key!(key)
|
|
26
26
|
mentry = @manifest.resolver.resolve(key).entry
|
|
27
27
|
|
|
28
|
-
guard_for(:
|
|
28
|
+
guard_for(:key_delete, key, if_etag: if_etag).check!(eval_for(:key_delete, target_key: key))
|
|
29
29
|
|
|
30
30
|
writer.delete(key, mentry: mentry, if_etag: if_etag)
|
|
31
31
|
|