textus 0.49.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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +176 -197
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +33 -28
  7. data/lib/textus/boot.rb +58 -47
  8. data/lib/textus/call.rb +1 -1
  9. data/lib/textus/cli/runner.rb +15 -10
  10. data/lib/textus/cli/verb/get.rb +1 -3
  11. data/lib/textus/cli/verb/hook_run.rb +1 -1
  12. data/lib/textus/cli/verb/put.rb +4 -20
  13. data/lib/textus/cli.rb +1 -4
  14. data/lib/textus/dispatcher.rb +1 -3
  15. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  16. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  17. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  18. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  19. data/lib/textus/doctor/check/sentinels.rb +2 -2
  20. data/lib/textus/doctor/check/templates.rb +13 -11
  21. data/lib/textus/doctor.rb +0 -2
  22. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  23. data/lib/textus/domain/freshness/verdict.rb +28 -6
  24. data/lib/textus/domain/freshness.rb +4 -33
  25. data/lib/textus/domain/policy/base_guards.rb +1 -1
  26. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  27. data/lib/textus/domain/policy/publish_target.rb +34 -0
  28. data/lib/textus/domain/policy/retention.rb +29 -0
  29. data/lib/textus/domain/policy/source.rb +79 -0
  30. data/lib/textus/domain/retention/sweep.rb +57 -0
  31. data/lib/textus/domain/retention.rb +11 -0
  32. data/lib/textus/errors.rb +4 -4
  33. data/lib/textus/hooks/builtin.rb +5 -5
  34. data/lib/textus/hooks/catalog.rb +8 -7
  35. data/lib/textus/hooks/context.rb +5 -10
  36. data/lib/textus/init/templates/machine_intake.rb +4 -4
  37. data/lib/textus/init.rb +47 -47
  38. data/lib/textus/key/matching.rb +24 -0
  39. data/lib/textus/maintenance/reconcile.rb +160 -0
  40. data/lib/textus/manifest/capabilities.rb +1 -1
  41. data/lib/textus/manifest/data.rb +2 -2
  42. data/lib/textus/manifest/entry/base.rb +28 -8
  43. data/lib/textus/manifest/entry/nested.rb +3 -4
  44. data/lib/textus/manifest/entry/parser.rb +25 -21
  45. data/lib/textus/manifest/entry/produced.rb +56 -0
  46. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  47. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  48. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  49. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  50. data/lib/textus/manifest/entry/validators.rb +0 -1
  51. data/lib/textus/manifest/policy.rb +16 -4
  52. data/lib/textus/manifest/resolver.rb +10 -4
  53. data/lib/textus/manifest/rules.rb +37 -36
  54. data/lib/textus/manifest/schema/keys.rb +98 -0
  55. data/lib/textus/manifest/schema/validator.rb +324 -0
  56. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  57. data/lib/textus/manifest/schema.rb +27 -247
  58. data/lib/textus/manifest.rb +5 -3
  59. data/lib/textus/mcp/server.rb +9 -2
  60. data/lib/textus/ports/audit_log.rb +6 -0
  61. data/lib/textus/ports/build_lock.rb +6 -0
  62. data/lib/textus/ports/clock.rb +4 -3
  63. data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
  64. data/lib/textus/ports/publisher.rb +11 -7
  65. data/lib/textus/produce/acquire/handler.rb +29 -0
  66. data/lib/textus/produce/acquire/intake.rb +130 -0
  67. data/lib/textus/produce/acquire/projection.rb +127 -0
  68. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  69. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  70. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  71. data/lib/textus/produce/acquire/serializer.rb +17 -0
  72. data/lib/textus/produce/engine.rb +143 -0
  73. data/lib/textus/produce/events.rb +36 -0
  74. data/lib/textus/produce/render.rb +23 -0
  75. data/lib/textus/projection.rb +17 -6
  76. data/lib/textus/read/boot.rb +4 -2
  77. data/lib/textus/read/deps.rb +3 -3
  78. data/lib/textus/read/freshness.rb +63 -29
  79. data/lib/textus/read/get.rb +20 -102
  80. data/lib/textus/read/rdeps.rb +3 -3
  81. data/lib/textus/read/rule_explain.rb +41 -23
  82. data/lib/textus/read/rule_list.rb +25 -8
  83. data/lib/textus/read/validate_all.rb +14 -0
  84. data/lib/textus/role.rb +2 -1
  85. data/lib/textus/schemas.rb +8 -0
  86. data/lib/textus/store.rb +1 -0
  87. data/lib/textus/version.rb +1 -1
  88. data/lib/textus/write/put.rb +1 -1
  89. metadata +23 -30
  90. data/lib/textus/builder/pipeline.rb +0 -88
  91. data/lib/textus/builder/renderer/json.rb +0 -45
  92. data/lib/textus/builder/renderer/markdown.rb +0 -24
  93. data/lib/textus/builder/renderer/text.rb +0 -14
  94. data/lib/textus/builder/renderer/yaml.rb +0 -45
  95. data/lib/textus/builder/renderer.rb +0 -17
  96. data/lib/textus/cli/verb/boot.rb +0 -13
  97. data/lib/textus/cli/verb/build.rb +0 -15
  98. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  99. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  100. data/lib/textus/domain/freshness/policy.rb +0 -18
  101. data/lib/textus/domain/lifecycle.rb +0 -83
  102. data/lib/textus/domain/outcome.rb +0 -10
  103. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  104. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  105. data/lib/textus/domain/staleness.rb +0 -29
  106. data/lib/textus/maintenance/tend.rb +0 -110
  107. data/lib/textus/manifest/entry/derived.rb +0 -65
  108. data/lib/textus/manifest/entry/intake.rb +0 -31
  109. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  110. data/lib/textus/mcp/tools.rb +0 -14
  111. data/lib/textus/ports/fetch/detached.rb +0 -52
  112. data/lib/textus/ports/fetch/lock.rb +0 -44
  113. data/lib/textus/write/build.rb +0 -90
  114. data/lib/textus/write/fetch_events.rb +0 -42
  115. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  116. data/lib/textus/write/fetch_worker.rb +0 -127
  117. data/lib/textus/write/intake_fetch.rb +0 -25
  118. data/lib/textus/write/materializer.rb +0 -51
@@ -0,0 +1,143 @@
1
+ module Textus
2
+ module Produce
3
+ # The single convergence engine (ADR 0093/0094). "Make these machine entries
4
+ # current from upstream." Acquire is per-`from`; publish is one uniform
5
+ # `publish_via` entry point for all kinds (ADR 0094):
6
+ # intake (from: handler) -> re-pull (Produce::Acquire::Intake), then publish_via
7
+ # derived (from: project) -> build data + publish_via (ToPaths or None)
8
+ # derived (from: command) -> skip the build; publish_via publishes
9
+ # existing store bytes via mode resolution
10
+ # (None when no targets -> skipped)
11
+ # Runs as the reconcile build actor (self-elevating); the passed `call`
12
+ # supplies only correlation_id/dry_run. Callers choose the key set: the
13
+ # write subscriber passes rdeps ∩ derived; reconcile passes
14
+ # all-derived + stale-intake.
15
+ class Engine
16
+ # Locked + failure-isolated convergence — the shared entry point for the
17
+ # write trigger (ADR 0093). Both the sync path (inline, in the subscriber)
18
+ # and the async path (AsyncRunner) call this. A held lock is a soft miss
19
+ # (an in-flight build/reconcile already produces fresh output); any other
20
+ # error is republished as :produce_failed and never raised at the
21
+ # writer (ADR 0087 §5 failure isolation, preserved).
22
+ def self.converge(container:, call:, keys:)
23
+ Textus::Ports::BuildLock.with(root: container.root) do
24
+ new(container: container, call: call).call(keys: keys)
25
+ end
26
+ rescue Textus::BuildInProgress
27
+ nil
28
+ rescue Textus::Error => e
29
+ container.events.publish(
30
+ :produce_failed,
31
+ ctx: Textus::Hooks::Context.for(container: container, call: call),
32
+ keys: keys, error: e.message
33
+ )
34
+ end
35
+
36
+ def initialize(container:, call:)
37
+ @container = container
38
+ @call = call
39
+ @manifest = container.manifest
40
+ end
41
+
42
+ # keys: the machine entry keys to converge. Returns
43
+ # { produced: [k...], skipped: [k...], failed: [{ "key"=>, "error"=> }...] }
44
+ def call(keys:)
45
+ build_call = build_actor_call
46
+ context = build_context(build_call)
47
+ out = { produced: [], skipped: [], failed: [] }
48
+
49
+ keys.each do |key|
50
+ produce_one(key, build_call, context, out)
51
+ rescue Textus::Error => e
52
+ out[:failed] << { "key" => key, "error" => e.message }
53
+ end
54
+ out
55
+ end
56
+
57
+ private
58
+
59
+ # Acquire is per-`from`; publish is one uniform entry point (publish_via)
60
+ # for every kind. The command emit-vs-skip falls out of publish-mode
61
+ # resolution (Publish::None when no targets), so there is no command branch.
62
+ def produce_one(key, build_call, context, out)
63
+ entry = @manifest.resolver.resolve(key).entry
64
+
65
+ if entry.intake?
66
+ Textus::Produce::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
67
+ entry.publish_via(context) # emit any targets
68
+ out[:produced] << key # a fetch is production
69
+ else
70
+ result = entry.publish_via(context) # derived builds inside; command publishes-or-None
71
+ result.nil? ? (out[:skipped] << key) : (out[:produced] << key)
72
+ end
73
+ end
74
+
75
+ def build_actor_call
76
+ build_role = @manifest.policy.actor_for("reconcile") or
77
+ raise Textus::UsageError.new(
78
+ "no role holds the 'reconcile' capability",
79
+ hint: "declare a role with `can: [reconcile]` in .textus/manifest.yaml",
80
+ )
81
+ Textus::Call.build(
82
+ role: build_role,
83
+ correlation_id: @call.correlation_id,
84
+ dry_run: @call.dry_run,
85
+ )
86
+ end
87
+
88
+ def build_context(call)
89
+ Textus::Manifest::Entry::Base::PublishContext.new(
90
+ container: @container, call: call,
91
+ reader: Textus::Read::Get.new(container: @container, call: call)
92
+ )
93
+ end
94
+
95
+ # In-process deferral for the async write trigger (ADR 0087/0093).
96
+ # Spawns a tracked thread that runs Produce.converge after the write
97
+ # returns; a one-time at_exit joins
98
+ # all pending threads so a short-lived CLI process cannot exit before an
99
+ # async rebuild completes. The write itself never blocks.
100
+ module AsyncRunner
101
+ @mutex = Mutex.new
102
+ @threads = []
103
+ @hooked = false
104
+
105
+ class << self
106
+ def enqueue(container:, call:, keys:)
107
+ thread = Thread.new { Textus::Produce::Engine.converge(container: container, call: call, keys: keys) }
108
+ track(thread)
109
+ thread
110
+ end
111
+
112
+ # Block until every spawned async rebuild has finished. Idempotent;
113
+ # safe to call from at_exit and directly from tests.
114
+ def drain
115
+ pending = @mutex.synchronize { @threads.dup }
116
+ pending.each(&:join)
117
+ @mutex.synchronize { @threads.delete_if { |t| !t.alive? } }
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ def track(thread)
124
+ @mutex.synchronize do
125
+ @threads.delete_if { |t| !t.alive? }
126
+ @threads << thread
127
+ install_drain_hook
128
+ end
129
+ end
130
+
131
+ # Register the join-before-exit hook exactly once. Guarded by the
132
+ # caller holding @mutex.
133
+ def install_drain_hook
134
+ return if @hooked
135
+
136
+ @hooked = true
137
+ at_exit { drain }
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -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
@@ -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 }`. Caller picks
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.
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 ? row : row.merge("_key" => key)
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, _body)
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({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
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
 
@@ -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
@@ -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.is_a?(Textus::Manifest::Entry::Derived)
24
+ return [] unless entry&.derived?
25
25
 
26
26
  src = entry.source
27
- result = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
27
+ result = if src.projection?
28
28
  Array(src.select).compact
29
- elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
29
+ elsif src.external?
30
30
  Array(src.sources).compact
31
31
  else
32
32
  []
@@ -2,20 +2,30 @@ require "time"
2
2
 
3
3
  module Textus
4
4
  module Read
5
- # Per-entry lifecycle report (ADR 0079). Walks every entry declared in the
6
- # manifest, consults `rules.for(key)` for a `lifecycle:` policy, and reports
7
- # the unified verdict. Status is one of :fresh, :expired, or :no_policy; the
8
- # row also carries the policy's :action (on_expire).
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).
16
+ #
17
+ # ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
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.
9
22
  class Freshness
10
23
  extend Textus::Contract::DSL
11
24
 
12
25
  verb :freshness
13
- summary "Report the fetch-freshness status of every entry with a fetch policy."
14
- surfaces :cli
15
- cli "freshness"
26
+ summary "Internal per-entry lifecycle scan (status, age, ttl, action); backs pulse + hook context. No public surface (ADR 0085)."
16
27
  arg :prefix, String, required: false, description: "filter to keys with this prefix"
17
28
  arg :zone, String, required: false, description: "filter to entries in this zone"
18
- view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
19
29
 
20
30
  def initialize(container:, call:)
21
31
  @container = container
@@ -50,29 +60,59 @@ module Textus
50
60
  private
51
61
 
52
62
  def row_for(mentry)
53
- policy = lifecycle_for(mentry.key)
54
63
  envelope = safe_get(mentry.key)
55
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?
56
67
 
57
- return base_row(mentry, last).merge(status: :no_policy) if policy.nil?
58
-
59
- expired, reason = Textus::Domain::Lifecycle.verdict(
60
- policy: policy,
61
- last_fetched_at: last,
62
- mtime: mtime_for(mentry.key),
63
- now: @call.now,
64
- )
68
+ basis = basis_for(mentry)
69
+ expired = expired?(mentry, basis, ttl)
65
70
  base_row(mentry, last).merge(
66
- ttl_seconds: policy.ttl_seconds,
67
- action: policy.on_expire,
71
+ ttl_seconds: ttl,
72
+ action: action,
68
73
  status: expired ? :expired : :fresh,
69
- reason: reason,
70
- next_due_at: next_due(last, policy.ttl_seconds),
74
+ next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
71
75
  )
72
76
  end
73
77
 
74
- def lifecycle_for(key)
75
- @manifest.rules.for(key).lifecycle
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
+ )
76
116
  end
77
117
 
78
118
  def mtime_for(key)
@@ -107,12 +147,6 @@ module Textus
107
147
  rescue Textus::Error
108
148
  nil
109
149
  end
110
-
111
- def next_due(last, ttl)
112
- return nil if last.nil? || ttl.nil?
113
-
114
- (Time.parse(last) + ttl).utc.iso8601
115
- end
116
150
  end
117
151
  end
118
152
  end
@@ -1,119 +1,59 @@
1
1
  module Textus
2
2
  module Read
3
- # The one read path. `fetch:` controls behavior:
4
- # fetch: false (default) pure read: the on-disk envelope annotated with
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).
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. 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)."
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:, orchestrator: nil, file_stat: Textus::Ports::Storage::FileStat.new)
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, fetch: false)
42
- envelope = annotated_envelope(key)
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, fetch: false)
57
- call(key, fetch: fetch) ||
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
- policy = lifecycle_for(key)
69
- return annotate_fresh(envelope) if policy.nil?
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 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,
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
@@ -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.is_a?(Textus::Manifest::Entry::Derived)
24
+ next unless e.derived?
25
25
 
26
26
  src = e.source
27
- sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
27
+ sources = if src.projection?
28
28
  Array(src.select).compact
29
- elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
29
+ elsif src.external?
30
30
  Array(src.sources).compact
31
31
  else
32
32
  []