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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +174 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +13 -17
  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 -3
  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 -9
  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 +1 -1
  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/deps.rb +3 -3
  77. data/lib/textus/read/freshness.rb +61 -31
  78. data/lib/textus/read/get.rb +20 -102
  79. data/lib/textus/read/rdeps.rb +3 -3
  80. data/lib/textus/read/rule_explain.rb +41 -23
  81. data/lib/textus/read/rule_list.rb +25 -8
  82. data/lib/textus/read/validate_all.rb +14 -0
  83. data/lib/textus/role.rb +2 -1
  84. data/lib/textus/schemas.rb +8 -0
  85. data/lib/textus/store.rb +1 -0
  86. data/lib/textus/version.rb +1 -1
  87. data/lib/textus/write/put.rb +1 -1
  88. metadata +23 -30
  89. data/lib/textus/builder/pipeline.rb +0 -88
  90. data/lib/textus/builder/renderer/json.rb +0 -45
  91. data/lib/textus/builder/renderer/markdown.rb +0 -24
  92. data/lib/textus/builder/renderer/text.rb +0 -14
  93. data/lib/textus/builder/renderer/yaml.rb +0 -45
  94. data/lib/textus/builder/renderer.rb +0 -17
  95. data/lib/textus/cli/verb/boot.rb +0 -14
  96. data/lib/textus/cli/verb/build.rb +0 -15
  97. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  98. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  99. data/lib/textus/domain/freshness/policy.rb +0 -18
  100. data/lib/textus/domain/lifecycle.rb +0 -83
  101. data/lib/textus/domain/outcome.rb +0 -10
  102. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  103. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  104. data/lib/textus/domain/staleness.rb +0 -29
  105. data/lib/textus/maintenance/tend.rb +0 -110
  106. data/lib/textus/manifest/entry/derived.rb +0 -67
  107. data/lib/textus/manifest/entry/intake.rb +0 -31
  108. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  109. data/lib/textus/mcp/tools.rb +0 -14
  110. data/lib/textus/ports/fetch/detached.rb +0 -52
  111. data/lib/textus/ports/fetch/lock.rb +0 -44
  112. data/lib/textus/write/build.rb +0 -90
  113. data/lib/textus/write/fetch_events.rb +0 -42
  114. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  115. data/lib/textus/write/fetch_worker.rb +0 -127
  116. data/lib/textus/write/intake_fetch.rb +0 -25
  117. 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
@@ -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
 
@@ -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,22 +2,28 @@ require "time"
2
2
 
3
3
  module Textus
4
4
  module Read
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).
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 (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.
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, on_expire); backs pulse + hook context. No public surface (ADR 0085)."
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
- return base_row(mentry, last).merge(status: :no_policy) if policy.nil?
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: policy.ttl_seconds,
71
- action: policy.on_expire,
71
+ ttl_seconds: ttl,
72
+ action: action,
72
73
  status: expired ? :expired : :fresh,
73
- reason: reason,
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
- def lifecycle_for(key)
79
- @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
+ )
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
@@ -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
  []
@@ -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 (fetch ttl/action, write guard, ...)"
17
+ description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
18
18
  arg :detail, :boolean,
19
- description: "defaults false: lean {fetch, guard}. detail: true adds matched blocks + guard predicates per transition."
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
- "lifecycle" => set.lifecycle && {
38
- "ttl_seconds" => set.lifecycle.ttl_seconds,
39
- "on_expire" => set.lifecycle.on_expire,
40
- "budget_ms" => set.lifecycle.budget_ms,
41
- },
42
- "guard" => set.guard,
43
- }.compact
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
- 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,
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 fetches/builds (explanation/concepts.md).
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
@@ -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
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.50.0"
2
+ VERSION = "0.51.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -39,7 +39,7 @@ module Textus
39
39
  if_etag: if_etag,
40
40
  )
41
41
 
42
- @events.publish(:entry_put,
42
+ @events.publish(:entry_written,
43
43
  ctx: hook_context,
44
44
  key: key,
45
45
  envelope: envelope)