textus 0.50.0 → 0.51.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 +26 -0
- data/README.md +41 -43
- data/SPEC.md +174 -176
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +31 -26
- data/lib/textus/boot.rb +13 -17
- data/lib/textus/call.rb +1 -1
- data/lib/textus/cli/runner.rb +15 -10
- data/lib/textus/cli/verb/get.rb +1 -3
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/put.rb +4 -20
- data/lib/textus/cli.rb +1 -3
- data/lib/textus/dispatcher.rb +1 -3
- data/lib/textus/doctor/check/generator_drift.rb +4 -3
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +13 -11
- data/lib/textus/doctor.rb +0 -2
- data/lib/textus/domain/freshness/evaluator.rb +150 -14
- data/lib/textus/domain/freshness/verdict.rb +28 -6
- data/lib/textus/domain/freshness.rb +4 -33
- data/lib/textus/domain/policy/base_guards.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
- data/lib/textus/domain/policy/publish_target.rb +34 -0
- data/lib/textus/domain/policy/retention.rb +29 -0
- data/lib/textus/domain/policy/source.rb +79 -0
- data/lib/textus/domain/retention/sweep.rb +57 -0
- data/lib/textus/domain/retention.rb +11 -0
- data/lib/textus/errors.rb +4 -4
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/catalog.rb +8 -7
- data/lib/textus/hooks/context.rb +5 -10
- data/lib/textus/init/templates/machine_intake.rb +4 -4
- data/lib/textus/init.rb +47 -47
- data/lib/textus/key/matching.rb +24 -0
- data/lib/textus/maintenance/reconcile.rb +160 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +2 -2
- data/lib/textus/manifest/entry/base.rb +28 -9
- data/lib/textus/manifest/entry/nested.rb +3 -4
- data/lib/textus/manifest/entry/parser.rb +25 -21
- data/lib/textus/manifest/entry/produced.rb +56 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
- data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
- data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
- data/lib/textus/manifest/entry/validators/publish.rb +3 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy.rb +16 -4
- data/lib/textus/manifest/resolver.rb +10 -4
- data/lib/textus/manifest/rules.rb +37 -36
- data/lib/textus/manifest/schema/keys.rb +98 -0
- data/lib/textus/manifest/schema/validator.rb +324 -0
- data/lib/textus/manifest/schema/vocabulary.rb +24 -0
- data/lib/textus/manifest/schema.rb +27 -247
- data/lib/textus/manifest.rb +5 -3
- data/lib/textus/mcp/server.rb +1 -1
- data/lib/textus/ports/audit_log.rb +6 -0
- data/lib/textus/ports/build_lock.rb +6 -0
- data/lib/textus/ports/clock.rb +4 -3
- data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
- data/lib/textus/ports/publisher.rb +11 -7
- data/lib/textus/produce/acquire/handler.rb +29 -0
- data/lib/textus/produce/acquire/intake.rb +130 -0
- data/lib/textus/produce/acquire/projection.rb +127 -0
- data/lib/textus/produce/acquire/serializer/json.rb +31 -0
- data/lib/textus/produce/acquire/serializer/text.rb +16 -0
- data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
- data/lib/textus/produce/acquire/serializer.rb +17 -0
- data/lib/textus/produce/engine.rb +143 -0
- data/lib/textus/produce/events.rb +36 -0
- data/lib/textus/produce/render.rb +23 -0
- data/lib/textus/projection.rb +17 -6
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +61 -31
- data/lib/textus/read/get.rb +20 -102
- data/lib/textus/read/rdeps.rb +3 -3
- data/lib/textus/read/rule_explain.rb +41 -23
- data/lib/textus/read/rule_list.rb +25 -8
- data/lib/textus/read/validate_all.rb +14 -0
- data/lib/textus/role.rb +2 -1
- data/lib/textus/schemas.rb +8 -0
- data/lib/textus/store.rb +1 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/put.rb +1 -1
- metadata +23 -30
- data/lib/textus/builder/pipeline.rb +0 -88
- data/lib/textus/builder/renderer/json.rb +0 -45
- data/lib/textus/builder/renderer/markdown.rb +0 -24
- data/lib/textus/builder/renderer/text.rb +0 -14
- data/lib/textus/builder/renderer/yaml.rb +0 -45
- data/lib/textus/builder/renderer.rb +0 -17
- data/lib/textus/cli/verb/boot.rb +0 -14
- data/lib/textus/cli/verb/build.rb +0 -15
- data/lib/textus/doctor/check/fetch_locks.rb +0 -49
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
- data/lib/textus/domain/freshness/policy.rb +0 -18
- data/lib/textus/domain/lifecycle.rb +0 -83
- data/lib/textus/domain/outcome.rb +0 -10
- data/lib/textus/domain/policy/lifecycle.rb +0 -35
- data/lib/textus/domain/staleness/generator_check.rb +0 -109
- data/lib/textus/domain/staleness.rb +0 -29
- data/lib/textus/maintenance/tend.rb +0 -110
- data/lib/textus/manifest/entry/derived.rb +0 -67
- data/lib/textus/manifest/entry/intake.rb +0 -31
- data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
- data/lib/textus/mcp/tools.rb +0 -14
- data/lib/textus/ports/fetch/detached.rb +0 -52
- data/lib/textus/ports/fetch/lock.rb +0 -44
- data/lib/textus/write/build.rb +0 -90
- data/lib/textus/write/fetch_events.rb +0 -42
- data/lib/textus/write/fetch_orchestrator.rb +0 -101
- data/lib/textus/write/fetch_worker.rb +0 -127
- data/lib/textus/write/intake_fetch.rb +0 -25
- data/lib/textus/write/materializer.rb +0 -51
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
# Single home for the fetch lifecycle event vocabulary (ADR 0048 D5).
|
|
4
|
+
# Produce::Acquire::Intake (the ingest executor driven by reconcile + hook) emits through
|
|
5
|
+
# this seam so the event names and payload shapes live in one place with one
|
|
6
|
+
# derived hook context.
|
|
7
|
+
class Events
|
|
8
|
+
def self.from(container:, call:)
|
|
9
|
+
new(
|
|
10
|
+
events: container.events,
|
|
11
|
+
hook_context: Textus::Hooks::Context.for(container: container, call: call),
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(events:, hook_context:)
|
|
16
|
+
@events = events
|
|
17
|
+
@hook_context = hook_context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def started(key, mode: :sync)
|
|
21
|
+
@events.publish(:entry_fetch_started, ctx: @hook_context, key: key, mode: mode)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def failed(key, error)
|
|
25
|
+
@events.publish(:entry_fetch_failed, ctx: @hook_context, key: key,
|
|
26
|
+
error_class: error.class.name, error_message: error.message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetched(key, envelope, change)
|
|
30
|
+
return if change == :unchanged
|
|
31
|
+
|
|
32
|
+
@events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
# Renders an entry's stored DATA into the bytes for one publish target
|
|
4
|
+
# (ADR 0094). Relocates the Mustache logic that used to live in the
|
|
5
|
+
# build-time Markdown renderer. Provenance is NOT added here — it lives in
|
|
6
|
+
# the data's `_meta`; a template surfaces it if the output should show it.
|
|
7
|
+
# A verbatim target (no template) is the caller's job to copy.
|
|
8
|
+
class Render
|
|
9
|
+
def initialize(template_loader:)
|
|
10
|
+
@template_loader = template_loader
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# target: a rendering Policy::PublishTarget. data: parsed entry data.
|
|
14
|
+
# boot: boot context hash or nil. Returns the rendered String.
|
|
15
|
+
def bytes_for(target:, data:, boot:)
|
|
16
|
+
raise ArgumentError.new("Produce::Render called for a verbatim target #{target.to.inspect}") unless target.renders?
|
|
17
|
+
|
|
18
|
+
ctx = target.inject_boot ? data.merge("boot" => boot) : data
|
|
19
|
+
Mustache.render(@template_loader.call(target.template), ctx)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/textus/projection.rb
CHANGED
|
@@ -6,9 +6,9 @@ module Textus
|
|
|
6
6
|
MAX_LIMIT = 1000
|
|
7
7
|
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
8
|
|
|
9
|
-
# `reader` — a callable `->(key) { envelope_or_nil }`.
|
|
10
|
-
#
|
|
11
|
-
# materialization
|
|
9
|
+
# `reader` — a callable `->(key) { envelope_or_nil }`. `Read::Get` is a pure
|
|
10
|
+
# read on every path (ADR 0089): it annotates freshness but never ingests,
|
|
11
|
+
# so materialization and any other reader share the same side-effect-free read.
|
|
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:`.
|
|
@@ -25,10 +25,15 @@ module Textus
|
|
|
25
25
|
def run
|
|
26
26
|
keys = collect_keys
|
|
27
27
|
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
28
|
+
pluck_key = explicit_pluck && Array(@spec["pluck"]).include?("_key")
|
|
28
29
|
rows = keys.map do |key|
|
|
29
30
|
env = @reader.call(key)
|
|
30
31
|
row = pluck(env.meta, env.body)
|
|
31
|
-
explicit_pluck
|
|
32
|
+
if explicit_pluck
|
|
33
|
+
pluck_key ? row.merge("_key" => key) : row
|
|
34
|
+
else
|
|
35
|
+
row.merge("_key" => key)
|
|
36
|
+
end
|
|
32
37
|
end
|
|
33
38
|
reduced = apply_reducer(rows)
|
|
34
39
|
# Reducers may return either an Array of rows (legacy / templated builds)
|
|
@@ -64,12 +69,18 @@ module Textus
|
|
|
64
69
|
prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
|
|
65
70
|
end
|
|
66
71
|
|
|
67
|
-
def pluck(frontmatter,
|
|
72
|
+
def pluck(frontmatter, body)
|
|
68
73
|
fields = @spec["pluck"]
|
|
69
74
|
if fields.nil? || fields == "*"
|
|
70
75
|
frontmatter
|
|
71
76
|
else
|
|
72
|
-
Array(fields).each_with_object({})
|
|
77
|
+
Array(fields).each_with_object({}) do |f, h|
|
|
78
|
+
if f == "body"
|
|
79
|
+
h["body"] = body
|
|
80
|
+
elsif frontmatter.key?(f)
|
|
81
|
+
h[f] = frontmatter[f]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
73
84
|
end
|
|
74
85
|
end
|
|
75
86
|
|
data/lib/textus/read/deps.rb
CHANGED
|
@@ -21,12 +21,12 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
def sources_for(key)
|
|
23
23
|
entry = @manifest.data.entries.find { |e| e.key == key }
|
|
24
|
-
return [] unless entry
|
|
24
|
+
return [] unless entry&.derived?
|
|
25
25
|
|
|
26
26
|
src = entry.source
|
|
27
|
-
result = if src.
|
|
27
|
+
result = if src.projection?
|
|
28
28
|
Array(src.select).compact
|
|
29
|
-
elsif src.
|
|
29
|
+
elsif src.external?
|
|
30
30
|
Array(src.sources).compact
|
|
31
31
|
else
|
|
32
32
|
[]
|
|
@@ -2,22 +2,28 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Read
|
|
5
|
-
# Per-entry
|
|
6
|
-
# the manifest
|
|
7
|
-
#
|
|
8
|
-
# :
|
|
5
|
+
# Per-entry staleness scan (ADR 0079, 0085, 0093). Walks every entry declared
|
|
6
|
+
# in the manifest and reports a staleness verdict sourced from the two new
|
|
7
|
+
# policy slots (ADR 0093):
|
|
8
|
+
# - intake entries: `entry.source.ttl_seconds` is the re-pull cadence;
|
|
9
|
+
# basis = `_meta.last_fetched_at` (else file mtime). Past ttl ⇒ :expired.
|
|
10
|
+
# - entries matched by a `retention:` rule: `retention.ttl_seconds` is the
|
|
11
|
+
# GC age; basis = file mtime. Past ttl ⇒ :expired (:action = drop/archive).
|
|
12
|
+
# Intake cadence wins when both apply (freshness is content currency; GC dueness
|
|
13
|
+
# shows via `reconcile --dry-run`).
|
|
14
|
+
# Status is one of :fresh, :expired, or :no_policy; the row also carries
|
|
15
|
+
# :action (:refresh for intake, :drop/:archive for retention).
|
|
9
16
|
#
|
|
10
17
|
# ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
|
|
11
|
-
# surface. This is now a Ruby-only internal scan
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# (the ttl / on_expire policy) instead of a dedicated verb.
|
|
18
|
+
# surface. This is now a Ruby-only internal scan consumed by `pulse` (which
|
|
19
|
+
# derives `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
|
|
20
|
+
# into per-entry staleness detail via `get` (last_fetched_at) + `rule_explain`
|
|
21
|
+
# (the ttl / action policy) instead of a dedicated verb.
|
|
16
22
|
class Freshness
|
|
17
23
|
extend Textus::Contract::DSL
|
|
18
24
|
|
|
19
25
|
verb :freshness
|
|
20
|
-
summary "Internal per-entry lifecycle scan (status, age, ttl,
|
|
26
|
+
summary "Internal per-entry lifecycle scan (status, age, ttl, action); backs pulse + hook context. No public surface (ADR 0085)."
|
|
21
27
|
arg :prefix, String, required: false, description: "filter to keys with this prefix"
|
|
22
28
|
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
23
29
|
|
|
@@ -54,29 +60,59 @@ module Textus
|
|
|
54
60
|
private
|
|
55
61
|
|
|
56
62
|
def row_for(mentry)
|
|
57
|
-
policy = lifecycle_for(mentry.key)
|
|
58
63
|
envelope = safe_get(mentry.key)
|
|
59
64
|
last = envelope&.meta&.dig("last_fetched_at")
|
|
65
|
+
ttl, action = policy_for(mentry)
|
|
66
|
+
return base_row(mentry, last).merge(status: :no_policy) if ttl.nil?
|
|
60
67
|
|
|
61
|
-
|
|
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
|
-
)
|
|
68
|
+
basis = basis_for(mentry)
|
|
69
|
+
expired = expired?(mentry, basis, ttl)
|
|
69
70
|
base_row(mentry, last).merge(
|
|
70
|
-
ttl_seconds:
|
|
71
|
-
action:
|
|
71
|
+
ttl_seconds: ttl,
|
|
72
|
+
action: action,
|
|
72
73
|
status: expired ? :expired : :fresh,
|
|
73
|
-
|
|
74
|
-
next_due_at: next_due(last, policy.ttl_seconds),
|
|
74
|
+
next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
|
|
75
75
|
)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
# ADR 0093: staleness comes from the intake re-pull cadence (source.ttl)
|
|
79
|
+
# or a retention GC rule (retention.ttl). Intake cadence wins when an entry
|
|
80
|
+
# has both (freshness is about content currency; GC dueness still shows via
|
|
81
|
+
# `reconcile --dry-run`). Returns [ttl_seconds, action] or [nil, nil].
|
|
82
|
+
def policy_for(mentry)
|
|
83
|
+
if mentry.intake?
|
|
84
|
+
ttl = mentry.source.ttl_seconds
|
|
85
|
+
return [ttl, :refresh] unless ttl.nil?
|
|
86
|
+
end
|
|
87
|
+
ret = @manifest.rules.for(mentry.key).retention
|
|
88
|
+
return [ret.ttl_seconds, ret.action] unless ret.nil?
|
|
89
|
+
|
|
90
|
+
[nil, nil]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Intake currency basis comes from the evaluator (single definition);
|
|
94
|
+
# retention dueness is keyed off file mtime.
|
|
95
|
+
def basis_for(mentry)
|
|
96
|
+
return evaluator.intake_basis(mentry) if mentry.intake? && mentry.source.ttl_seconds
|
|
97
|
+
|
|
98
|
+
mtime_for(mentry.key)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def expired?(mentry, basis, ttl)
|
|
102
|
+
if mentry.intake? && mentry.source.ttl_seconds
|
|
103
|
+
evaluator.verdict(mentry).stale
|
|
104
|
+
else
|
|
105
|
+
# Preserve pre-0099 pulse semantics: a never-recorded retention entry
|
|
106
|
+
# (no file => nil basis) is past due. Retention::Sweep.expired? alone
|
|
107
|
+
# returns false on nil mtime (it runs post-exists? in the sweep).
|
|
108
|
+
basis.nil? || Textus::Domain::Retention::Sweep.expired?(ttl_seconds: ttl, mtime: basis, now: @call.now)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def evaluator
|
|
113
|
+
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
114
|
+
manifest: @manifest, file_stat: Textus::Ports::Storage::FileStat.new, clock: @call,
|
|
115
|
+
)
|
|
80
116
|
end
|
|
81
117
|
|
|
82
118
|
def mtime_for(key)
|
|
@@ -111,12 +147,6 @@ module Textus
|
|
|
111
147
|
rescue Textus::Error
|
|
112
148
|
nil
|
|
113
149
|
end
|
|
114
|
-
|
|
115
|
-
def next_due(last, ttl)
|
|
116
|
-
return nil if last.nil? || ttl.nil?
|
|
117
|
-
|
|
118
|
-
(Time.parse(last) + ttl).utc.iso8601
|
|
119
|
-
end
|
|
120
150
|
end
|
|
121
151
|
end
|
|
122
152
|
end
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -1,119 +1,59 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
-
# The one read path
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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).
|
|
12
|
-
#
|
|
13
|
-
# Lifecycle policy comes from the unified `lifecycle:` rule slot (ADR 0079).
|
|
3
|
+
# The one read path — a pure read (ADR 0089, 0093): the on-disk envelope
|
|
4
|
+
# annotated with a freshness annotation. It NEVER mutates and NEVER ingests.
|
|
5
|
+
# Quarantine freshness is system-pushed via `reconcile` (scheduled sweep) and
|
|
6
|
+
# `hook run` (event push). Lifecycle is removed from the get path (ADR 0093):
|
|
7
|
+
# intake cadence lives in `source.ttl`; GC lives in `retention:` rules; both
|
|
8
|
+
# are evaluated exclusively by the `reconcile` sweep, not by a read.
|
|
14
9
|
class Get
|
|
15
10
|
extend Textus::Contract::DSL
|
|
16
11
|
|
|
17
12
|
verb :get
|
|
18
|
-
summary "Read one entry
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"_meta, body, freshness)."
|
|
13
|
+
summary "Read one entry — a pure on-disk read annotated with a freshness " \
|
|
14
|
+
"verdict; never ingests (quarantine freshness is reconcile + hook " \
|
|
15
|
+
"only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
|
|
16
|
+
"freshness)."
|
|
23
17
|
surfaces :cli, :mcp
|
|
24
18
|
arg :key, String, required: true, positional: true,
|
|
25
19
|
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
26
|
-
arg :fetch, :boolean, default: true,
|
|
27
|
-
description: "read-through (refresh on stale per the " \
|
|
28
|
-
"entry's lifecycle rule) when true, the default; " \
|
|
29
|
-
"false returns the on-disk envelope without ever fetching"
|
|
30
20
|
view { |v, _i| v.to_h_for_wire }
|
|
31
21
|
|
|
32
|
-
def initialize(container:, call:,
|
|
22
|
+
def initialize(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
|
|
33
23
|
@container = container
|
|
34
24
|
@call = call
|
|
35
25
|
@manifest = container.manifest
|
|
36
26
|
@file_store = container.file_store
|
|
37
27
|
@file_stat = file_stat
|
|
38
|
-
@orchestrator = orchestrator # nil → built lazily on first fetch only
|
|
39
28
|
end
|
|
40
29
|
|
|
41
|
-
def call(key
|
|
42
|
-
|
|
43
|
-
return envelope if envelope.nil?
|
|
44
|
-
return envelope unless fetch && envelope.freshness&.stale
|
|
45
|
-
|
|
46
|
-
policy = lifecycle_for(key)
|
|
47
|
-
return envelope unless policy&.on_expire == :refresh # only refresh acts on a read
|
|
48
|
-
|
|
49
|
-
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
50
|
-
outcome = orchestrator.execute(refresh_policy(policy).decide(verdict), key: key)
|
|
51
|
-
resolve(outcome, envelope)
|
|
30
|
+
def call(key)
|
|
31
|
+
annotated_envelope(key)
|
|
52
32
|
end
|
|
53
33
|
|
|
54
34
|
# Strict variant: raises UnknownKey when the entry is missing.
|
|
55
35
|
# Used by consumers (e.g. uid, Validator) that distinguish absence.
|
|
56
|
-
def get(key
|
|
57
|
-
call(key
|
|
36
|
+
def get(key)
|
|
37
|
+
call(key) ||
|
|
58
38
|
raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
59
39
|
end
|
|
60
40
|
|
|
61
41
|
private
|
|
62
42
|
|
|
63
|
-
# Pure read + unified lifecycle verdict; no orchestrator dependency.
|
|
64
43
|
def annotated_envelope(key)
|
|
65
44
|
envelope = read_raw_envelope(key)
|
|
66
45
|
return nil if envelope.nil?
|
|
67
46
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
78
|
-
stale: expired, reason: reason, fetching: false,
|
|
79
|
-
))
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def lifecycle_for(key)
|
|
83
|
-
@manifest.rules.for(key).lifecycle
|
|
47
|
+
entry = @manifest.resolver.resolve(key).entry
|
|
48
|
+
envelope.with(freshness: evaluator.verdict(entry))
|
|
84
49
|
end
|
|
85
50
|
|
|
86
|
-
def
|
|
87
|
-
Textus::Domain::Freshness::
|
|
88
|
-
|
|
89
|
-
on_stale: policy.budget_ms ? :timed_sync : :sync,
|
|
90
|
-
sync_budget_ms: policy.budget_ms,
|
|
51
|
+
def evaluator
|
|
52
|
+
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
53
|
+
manifest: @manifest, file_stat: @file_stat, clock: @call,
|
|
91
54
|
)
|
|
92
55
|
end
|
|
93
56
|
|
|
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
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def resolve(outcome, envelope)
|
|
102
|
-
case outcome
|
|
103
|
-
when Textus::Domain::Outcome::Skipped
|
|
104
|
-
envelope
|
|
105
|
-
when Textus::Domain::Outcome::Fetched
|
|
106
|
-
outcome.envelope.with(
|
|
107
|
-
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
|
|
108
|
-
)
|
|
109
|
-
when Textus::Domain::Outcome::Detached
|
|
110
|
-
envelope.with(freshness: envelope.freshness.with(fetching: true))
|
|
111
|
-
when Textus::Domain::Outcome::Failed
|
|
112
|
-
envelope.with(freshness: envelope.freshness.with(fetch_error: outcome.error.message))
|
|
113
|
-
else raise "unexpected fetch outcome: #{outcome.class}"
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
57
|
def read_raw_envelope(key)
|
|
118
58
|
res = @manifest.resolver.resolve(key)
|
|
119
59
|
mentry = res.entry
|
|
@@ -128,28 +68,6 @@ module Textus
|
|
|
128
68
|
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
129
69
|
)
|
|
130
70
|
end
|
|
131
|
-
|
|
132
|
-
def annotate_fresh(envelope)
|
|
133
|
-
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
134
|
-
stale: false, reason: nil, fetching: false,
|
|
135
|
-
))
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def orchestrator
|
|
139
|
-
@orchestrator ||= build_orchestrator
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def build_orchestrator
|
|
143
|
-
worker = Textus::Write::FetchWorker.new(container: @container, call: @call)
|
|
144
|
-
Textus::Write::FetchOrchestrator.new(
|
|
145
|
-
worker: worker, store_root: @container.root, events: @container.events,
|
|
146
|
-
hook_context: hook_context
|
|
147
|
-
)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def hook_context
|
|
151
|
-
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
152
|
-
end
|
|
153
71
|
end
|
|
154
72
|
end
|
|
155
73
|
end
|
data/lib/textus/read/rdeps.rb
CHANGED
|
@@ -21,12 +21,12 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
def dependents_of(key)
|
|
23
23
|
@manifest.data.entries.each_with_object([]) do |e, acc|
|
|
24
|
-
next unless e.
|
|
24
|
+
next unless e.derived?
|
|
25
25
|
|
|
26
26
|
src = e.source
|
|
27
|
-
sources = if src.
|
|
27
|
+
sources = if src.projection?
|
|
28
28
|
Array(src.select).compact
|
|
29
|
-
elsif src.
|
|
29
|
+
elsif src.external?
|
|
30
30
|
Array(src.sources).compact
|
|
31
31
|
else
|
|
32
32
|
[]
|
|
@@ -14,9 +14,9 @@ module Textus
|
|
|
14
14
|
surfaces :cli, :mcp
|
|
15
15
|
cli "rule explain"
|
|
16
16
|
arg :key, String, required: true, positional: true,
|
|
17
|
-
description: "dotted key whose effective rules you want (
|
|
17
|
+
description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
|
|
18
18
|
arg :detail, :boolean,
|
|
19
|
-
description: "defaults false: lean {
|
|
19
|
+
description: "defaults false: lean {lifecycle, guard}. detail: true adds matched blocks + guard predicates per transition."
|
|
20
20
|
view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
|
|
21
21
|
|
|
22
22
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
@@ -24,6 +24,16 @@ module Textus
|
|
|
24
24
|
@schemas = container.schemas
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
|
|
28
|
+
# Field membership is registry-driven (WS3). Lean shows the fields tagged
|
|
29
|
+
# for :lean; detail's matched_blocks flag every :detail field. The
|
|
30
|
+
# `effective` value-block shows the instantiated-policy fields (those with
|
|
31
|
+
# a policy_class) — guard, being a raw deferred field, is surfaced through
|
|
32
|
+
# the dedicated `guards:` predicate section instead.
|
|
33
|
+
LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
|
|
34
|
+
DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
|
|
35
|
+
EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
|
|
36
|
+
|
|
27
37
|
def call(key, detail: false)
|
|
28
38
|
detail ? explain(key) : effective(key)
|
|
29
39
|
end
|
|
@@ -33,14 +43,17 @@ module Textus
|
|
|
33
43
|
# Lean: the effective winners only (formerly Read::Rules / the `rules` verb).
|
|
34
44
|
def effective(key)
|
|
35
45
|
set = @manifest.rules.for(key)
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
LEAN_FIELDS.each_with_object({}) do |field, out|
|
|
47
|
+
value = set.public_send(field)
|
|
48
|
+
out[field.to_s] = lean_value(field, value) unless value.nil?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def lean_value(field, value)
|
|
53
|
+
case field
|
|
54
|
+
when :retention then retention_hash(value, string_keys: true)
|
|
55
|
+
else value
|
|
56
|
+
end
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
# Verbose: every matching block, per-slot effective value, and the
|
|
@@ -54,25 +67,30 @@ module Textus
|
|
|
54
67
|
{
|
|
55
68
|
key: key,
|
|
56
69
|
matched_blocks: matching.map do |b|
|
|
57
|
-
{
|
|
58
|
-
match: b.match,
|
|
59
|
-
lifecycle: !b.lifecycle.nil?,
|
|
60
|
-
handler_allowlist: !b.handler_allowlist.nil?,
|
|
61
|
-
guard: !b.guard.nil?,
|
|
62
|
-
}
|
|
70
|
+
{ match: b.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !b.public_send(f).nil?] })
|
|
63
71
|
end,
|
|
64
|
-
effective: {
|
|
65
|
-
lifecycle: winners.lifecycle && {
|
|
66
|
-
ttl_seconds: winners.lifecycle.ttl_seconds,
|
|
67
|
-
on_expire: winners.lifecycle.on_expire,
|
|
68
|
-
},
|
|
69
|
-
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
70
|
-
},
|
|
72
|
+
effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
|
|
71
73
|
guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
|
|
72
74
|
[transition, factory.for(transition, key).predicates.map(&:name)]
|
|
73
75
|
end,
|
|
74
76
|
}
|
|
75
77
|
end
|
|
78
|
+
|
|
79
|
+
def effective_value(field, value)
|
|
80
|
+
return nil if value.nil?
|
|
81
|
+
|
|
82
|
+
case field
|
|
83
|
+
when :retention then retention_hash(value, string_keys: false)
|
|
84
|
+
when :handler_allowlist then value.handlers
|
|
85
|
+
else value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ADR 0093: retention is a flat GC policy (ttl + drop/archive action).
|
|
90
|
+
def retention_hash(retention, string_keys:)
|
|
91
|
+
h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
|
|
92
|
+
string_keys ? h.transform_keys(&:to_s) : h
|
|
93
|
+
end
|
|
76
94
|
end
|
|
77
95
|
end
|
|
78
96
|
end
|
|
@@ -17,21 +17,38 @@ module Textus
|
|
|
17
17
|
@manifest = container.manifest
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Fields shown here are driven by FIELD_REGISTRY (in_rule_list); only the
|
|
21
|
+
# per-field serialization below is field-specific.
|
|
22
|
+
LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
|
|
23
|
+
|
|
20
24
|
def call
|
|
21
25
|
@manifest.rules.blocks.map do |b|
|
|
22
26
|
row = { "match" => b.match }
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"on_expire" => b.lifecycle.on_expire,
|
|
27
|
-
"budget_ms" => b.lifecycle.budget_ms,
|
|
28
|
-
}
|
|
27
|
+
LIST_FIELDS.each do |field|
|
|
28
|
+
value = b.public_send(field)
|
|
29
|
+
row[field.to_s] = serialize(field, value) unless value.nil?
|
|
29
30
|
end
|
|
30
|
-
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
31
|
-
row["guard"] = b.guard if b.guard
|
|
32
31
|
row
|
|
33
32
|
end
|
|
34
33
|
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def serialize(field, value)
|
|
38
|
+
case field
|
|
39
|
+
when :retention
|
|
40
|
+
serialize_retention(value)
|
|
41
|
+
when :handler_allowlist
|
|
42
|
+
value.handlers
|
|
43
|
+
else
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ADR 0093: retention is a flat GC policy.
|
|
49
|
+
def serialize_retention(retention)
|
|
50
|
+
{ "ttl_seconds" => retention.ttl_seconds, "action" => retention.action.to_s }
|
|
51
|
+
end
|
|
35
52
|
end
|
|
36
53
|
end
|
|
37
54
|
end
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
+
# Store-wide schema + role-authority validation: walks every entry and runs
|
|
4
|
+
# the Validator over it. Consumed internally by `doctor`'s schema_violations
|
|
5
|
+
# check and exposed as a Ruby store method (`store.validate_all`).
|
|
6
|
+
#
|
|
7
|
+
# Ruby-only, like `Read::Freshness`: it declares a contract (so it round-trips
|
|
8
|
+
# through the routing<->contract bijection, ADR 0105) but omits `surfaces`, so
|
|
9
|
+
# it gets no CLI or MCP projection. The public `validate-all` CLI verb was
|
|
10
|
+
# removed in v0.5 (`doctor --check=schema_violations` replaces it).
|
|
3
11
|
class ValidateAll
|
|
12
|
+
extend Textus::Contract::DSL
|
|
13
|
+
|
|
14
|
+
verb :validate_all
|
|
15
|
+
summary "Internal store-wide schema + role-authority validation; backs " \
|
|
16
|
+
"doctor's schema_violations check. No public surface (ADR 0105)."
|
|
17
|
+
|
|
4
18
|
def initialize(container:, call:)
|
|
5
19
|
@container = container
|
|
6
20
|
@call = call
|
data/lib/textus/role.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Role
|
|
3
3
|
# The three role archetypes, each string sourced exactly once: human curates
|
|
4
|
-
# canon, agent proposes, automation
|
|
4
|
+
# canon, agent proposes, automation reconciles the machine-maintained lanes
|
|
5
|
+
# (refresh + materialize) (explanation/concepts.md).
|
|
5
6
|
# Reference these constants instead of bare literals (ADR 0044).
|
|
6
7
|
HUMAN = "human".freeze
|
|
7
8
|
AGENT = "agent".freeze
|
data/lib/textus/schemas.rb
CHANGED
|
@@ -24,6 +24,14 @@ module Textus
|
|
|
24
24
|
@schemas.values
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Name-keyed view: { canonical_name => Schema }. The key is the schema's
|
|
28
|
+
# file stem, which is authoritative even when a schema file carries no
|
|
29
|
+
# top-level `name:` (Schema#name reads the body and may be nil). Symmetric
|
|
30
|
+
# with #all (values); use this when you need the names too.
|
|
31
|
+
def by_name
|
|
32
|
+
@schemas.dup
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
private
|
|
28
36
|
|
|
29
37
|
def load_all
|
data/lib/textus/store.rb
CHANGED
|
@@ -90,6 +90,7 @@ module Textus
|
|
|
90
90
|
|
|
91
91
|
def bootstrap_hooks
|
|
92
92
|
Ports::AuditSubscriber.new(audit_log).attach(events)
|
|
93
|
+
Ports::ProduceOnWriteSubscriber.new(container).attach(events)
|
|
93
94
|
Hooks::Builtin.register_all(events: events, rpc: rpc)
|
|
94
95
|
Hooks::Loader.new(events: events, rpc: rpc).load_dir(File.join(root, "hooks"))
|
|
95
96
|
end
|
data/lib/textus/version.rb
CHANGED