textus 0.50.0 → 0.52.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +176 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +15 -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/verb/serve.rb +19 -0
  14. data/lib/textus/cli.rb +1 -3
  15. data/lib/textus/dispatcher.rb +3 -3
  16. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  17. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  18. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  19. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  20. data/lib/textus/doctor/check/sentinels.rb +2 -2
  21. data/lib/textus/doctor/check/templates.rb +13 -11
  22. data/lib/textus/doctor.rb +0 -2
  23. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  24. data/lib/textus/domain/freshness/verdict.rb +28 -6
  25. data/lib/textus/domain/freshness.rb +4 -33
  26. data/lib/textus/domain/jobs/job.rb +58 -0
  27. data/lib/textus/domain/jobs/registry.rb +37 -0
  28. data/lib/textus/domain/policy/base_guards.rb +1 -1
  29. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  30. data/lib/textus/domain/policy/publish_target.rb +34 -0
  31. data/lib/textus/domain/policy/retention.rb +29 -0
  32. data/lib/textus/domain/policy/source.rb +73 -0
  33. data/lib/textus/domain/retention/sweep.rb +57 -0
  34. data/lib/textus/domain/retention.rb +11 -0
  35. data/lib/textus/errors.rb +4 -4
  36. data/lib/textus/hooks/builtin.rb +5 -5
  37. data/lib/textus/hooks/catalog.rb +7 -7
  38. data/lib/textus/hooks/context.rb +5 -10
  39. data/lib/textus/init/templates/machine_intake.rb +4 -4
  40. data/lib/textus/init.rb +47 -47
  41. data/lib/textus/jobs/handlers.rb +62 -0
  42. data/lib/textus/jobs/scheduler.rb +36 -0
  43. data/lib/textus/jobs/seeder.rb +57 -0
  44. data/lib/textus/key/matching.rb +24 -0
  45. data/lib/textus/layout.rb +8 -0
  46. data/lib/textus/maintenance/drain.rb +42 -0
  47. data/lib/textus/maintenance/retention/apply.rb +52 -0
  48. data/lib/textus/maintenance/serve.rb +30 -0
  49. data/lib/textus/maintenance/worker.rb +74 -0
  50. data/lib/textus/manifest/capabilities.rb +1 -1
  51. data/lib/textus/manifest/data.rb +18 -3
  52. data/lib/textus/manifest/entry/base.rb +28 -9
  53. data/lib/textus/manifest/entry/nested.rb +3 -4
  54. data/lib/textus/manifest/entry/parser.rb +25 -21
  55. data/lib/textus/manifest/entry/produced.rb +56 -0
  56. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  57. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  58. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  59. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  60. data/lib/textus/manifest/entry/validators.rb +0 -1
  61. data/lib/textus/manifest/policy.rb +16 -4
  62. data/lib/textus/manifest/resolver.rb +10 -4
  63. data/lib/textus/manifest/rules.rb +37 -36
  64. data/lib/textus/manifest/schema/keys.rb +98 -0
  65. data/lib/textus/manifest/schema/validator.rb +324 -0
  66. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  67. data/lib/textus/manifest/schema.rb +27 -247
  68. data/lib/textus/manifest.rb +5 -3
  69. data/lib/textus/mcp/server.rb +1 -1
  70. data/lib/textus/ports/audit_log.rb +6 -0
  71. data/lib/textus/ports/build_lock.rb +6 -0
  72. data/lib/textus/ports/clock.rb +4 -3
  73. data/lib/textus/ports/produce_on_write_subscriber.rb +73 -0
  74. data/lib/textus/ports/publisher.rb +11 -7
  75. data/lib/textus/ports/queue.rb +130 -0
  76. data/lib/textus/produce/acquire/handler.rb +29 -0
  77. data/lib/textus/produce/acquire/intake.rb +130 -0
  78. data/lib/textus/produce/acquire/projection.rb +127 -0
  79. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  80. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  81. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  82. data/lib/textus/produce/acquire/serializer.rb +17 -0
  83. data/lib/textus/produce/engine.rb +95 -0
  84. data/lib/textus/produce/events.rb +36 -0
  85. data/lib/textus/produce/render.rb +23 -0
  86. data/lib/textus/projection.rb +17 -6
  87. data/lib/textus/read/deps.rb +3 -3
  88. data/lib/textus/read/freshness.rb +61 -31
  89. data/lib/textus/read/get.rb +20 -102
  90. data/lib/textus/read/jobs.rb +31 -0
  91. data/lib/textus/read/rdeps.rb +3 -3
  92. data/lib/textus/read/rule_explain.rb +41 -23
  93. data/lib/textus/read/rule_list.rb +25 -8
  94. data/lib/textus/read/validate_all.rb +14 -0
  95. data/lib/textus/role.rb +2 -1
  96. data/lib/textus/schemas.rb +8 -0
  97. data/lib/textus/store.rb +1 -0
  98. data/lib/textus/version.rb +1 -1
  99. data/lib/textus/write/enqueue.rb +50 -0
  100. data/lib/textus/write/put.rb +1 -1
  101. metadata +35 -30
  102. data/lib/textus/builder/pipeline.rb +0 -88
  103. data/lib/textus/builder/renderer/json.rb +0 -45
  104. data/lib/textus/builder/renderer/markdown.rb +0 -24
  105. data/lib/textus/builder/renderer/text.rb +0 -14
  106. data/lib/textus/builder/renderer/yaml.rb +0 -45
  107. data/lib/textus/builder/renderer.rb +0 -17
  108. data/lib/textus/cli/verb/boot.rb +0 -14
  109. data/lib/textus/cli/verb/build.rb +0 -15
  110. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  111. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  112. data/lib/textus/domain/freshness/policy.rb +0 -18
  113. data/lib/textus/domain/lifecycle.rb +0 -83
  114. data/lib/textus/domain/outcome.rb +0 -10
  115. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  116. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  117. data/lib/textus/domain/staleness.rb +0 -29
  118. data/lib/textus/maintenance/tend.rb +0 -110
  119. data/lib/textus/manifest/entry/derived.rb +0 -67
  120. data/lib/textus/manifest/entry/intake.rb +0 -31
  121. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  122. data/lib/textus/mcp/tools.rb +0 -14
  123. data/lib/textus/ports/fetch/detached.rb +0 -52
  124. data/lib/textus/ports/fetch/lock.rb +0 -44
  125. data/lib/textus/write/build.rb +0 -90
  126. data/lib/textus/write/fetch_events.rb +0 -42
  127. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  128. data/lib/textus/write/fetch_worker.rb +0 -127
  129. data/lib/textus/write/intake_fetch.rb +0 -25
  130. data/lib/textus/write/materializer.rb +0 -51
@@ -0,0 +1,130 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ # Internal ingest executor for one machine-zone intake entry. No longer a
7
+ # public verb (ADR 0079 collapsed the `fetch` surface): used by the
8
+ # converge sweep (drain/serve) and `textus hook run` only — ingest is system-pushed
9
+ # (ADR 0089 removed the read-through that once also drove it).
10
+ class Intake
11
+ FETCH_TIMEOUT_SECONDS = Textus::Produce::Acquire::Handler::FETCH_TIMEOUT_SECONDS
12
+
13
+ def initialize(container:, call:)
14
+ @container = container
15
+ @call = call
16
+ @manifest = container.manifest
17
+ @schemas = container.schemas
18
+ @rpc = container.rpc
19
+ end
20
+
21
+ # call(key) is the primary entry; run is kept as an alias for
22
+ # Orchestrator and FetchAll which call worker.run(key).
23
+ def call(key)
24
+ run(key)
25
+ end
26
+
27
+ def run(key)
28
+ res = @manifest.resolver.resolve(key)
29
+ mentry = res.entry
30
+ path = res.path
31
+ remaining = res.remaining
32
+ raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake?
33
+
34
+ before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
35
+ result = fetch_with_events(key, mentry, remaining)
36
+ persist_and_notify(key, mentry, result, before_etag)
37
+ end
38
+
39
+ def self.normalize_action_result(res, format:)
40
+ res = res.transform_keys(&:to_s) if res.is_a?(Hash)
41
+ res ||= {}
42
+ meta_val = res["_meta"]
43
+ body = res["body"]
44
+ content = res["content"]
45
+
46
+ case format
47
+ when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
48
+ when "text" then { meta: {}, body: body.to_s, content: nil }
49
+ when "json", "yaml"
50
+ if !content.nil?
51
+ { meta: meta_val || {}, body: nil, content: content }
52
+ elsif !body.nil?
53
+ { meta: {}, body: body.to_s, content: nil }
54
+ else
55
+ raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
56
+ end
57
+ else
58
+ raise Textus::UsageError.new("unknown format #{format.inspect}")
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def fetch_events
65
+ @fetch_events ||= Textus::Produce::Events.from(container: @container, call: @call)
66
+ end
67
+
68
+ # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
69
+ # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
70
+ # applies to every intake.
71
+ def fetch_timeout_for(_key)
72
+ FETCH_TIMEOUT_SECONDS
73
+ end
74
+
75
+ def fetch_with_events(key, mentry, remaining)
76
+ fetch_events.started(key)
77
+ call_intake(key, mentry, remaining)
78
+ end
79
+
80
+ def call_intake(key, mentry, remaining)
81
+ Textus::Produce::Acquire::Handler.invoke(
82
+ caps: @container, handler: mentry.handler,
83
+ config: mentry.config,
84
+ args: { trigger_key: key, leaf_segments: remaining || [] },
85
+ label: "intake", timeout: fetch_timeout_for(key)
86
+ )
87
+ rescue Textus::Error => e
88
+ fetch_events.failed(key, e)
89
+ raise
90
+ rescue StandardError => e
91
+ fetch_events.failed(key, e)
92
+ raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
93
+ end
94
+
95
+ def persist_and_notify(key, mentry, result, before_etag)
96
+ normalized = self.class.normalize_action_result(result, format: mentry.format)
97
+ Textus::Domain::Policy::GuardFactory.new(
98
+ manifest: @manifest, schemas: @schemas,
99
+ ).for(:converge, key).check!(
100
+ Textus::Domain::Policy::Evaluation.new(
101
+ actor: @call.role, transition: :converge, origin: nil,
102
+ target: key, envelope: nil, manifest: @manifest
103
+ ),
104
+ )
105
+ envelope = writer.put(
106
+ key,
107
+ mentry: mentry,
108
+ payload: Textus::Envelope::IO::Writer::Payload.new(
109
+ meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
110
+ ),
111
+ )
112
+ change = detect_change(before_etag, envelope)
113
+ fetch_events.fetched(key, envelope, change)
114
+ envelope
115
+ end
116
+
117
+ def detect_change(before_etag, envelope)
118
+ if before_etag.nil? then :created
119
+ elsif envelope.etag == before_etag then :unchanged
120
+ else :updated
121
+ end
122
+ end
123
+
124
+ def writer
125
+ @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,127 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ # Builds an entry's DATA artifact (ADR 0094) by running the projection
7
+ # pipeline; rendering is a publish concern. External entries are NOT built
8
+ # here — they are generated by an out-of-band runner; Derived#publish_via
9
+ # filters them out before reaching this point.
10
+ #
11
+ # Merges the former Write::DataBuilder wrapper and Builder::Pipeline module
12
+ # into one class (ADR 0100 produce/ topology refactor).
13
+ class Projection
14
+ # Injects provenance metadata as the first key in the serialized output.
15
+ # Carries only deterministic provenance (`from`/`reduce`) — the volatile
16
+ # `generated_at` is deliberately NOT stamped, so the built artifact is
17
+ # content-addressed and a rebuild is a byte-for-byte no-op (ADR 0070).
18
+ # Build time lives out of the tracked artifact.
19
+ module InjectMeta
20
+ def self.call(content_hash, mentry)
21
+ meta = {}
22
+ if mentry.derived?
23
+ src = mentry.source
24
+ if src.projection?
25
+ from = Array(src.select).compact
26
+ meta["from"] = from unless from.empty?
27
+ meta["reduce"] = src.transform if src.transform
28
+ end
29
+ end
30
+
31
+ out = { "_meta" => meta }
32
+ content_hash.each { |k, v| out[k] = v unless k == "_meta" }
33
+ out
34
+ end
35
+ end
36
+
37
+ Deps = Data.define(:manifest, :reader, :lister, :rpc, :transform_context)
38
+
39
+ def self.renderers
40
+ @renderers ||= {
41
+ "text" => Produce::Acquire::Serializer::Text,
42
+ "json" => Produce::Acquire::Serializer::Json,
43
+ "yaml" => Produce::Acquire::Serializer::Yaml,
44
+ }
45
+ end
46
+
47
+ def initialize(container:, call:)
48
+ @container = container
49
+ @call = call
50
+ @manifest = container.manifest
51
+ @file_store = container.file_store
52
+ @rpc = container.rpc
53
+ @root = container.root
54
+ end
55
+
56
+ # Runs the projection pipeline for `mentry` and returns the on-disk
57
+ # target_path string.
58
+ def run(mentry)
59
+ reader = Textus::Read::Get.new(container: @container, call: @call)
60
+ # Projections must be able to read source data from any nested entry,
61
+ # including keyless (publish_tree) ones like knowledge.decisions.
62
+ # The `include_keyless: true` option makes the resolver walk those dirs
63
+ # without exposing them on the public `list` / CLI surface (ADR 0047).
64
+ resolver = @manifest.resolver
65
+ lister = lambda do |prefix:|
66
+ resolver.enumerate(prefix: prefix, include_keyless: true)
67
+ .map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
68
+ end
69
+ self.class.pipeline_run(
70
+ mentry: mentry,
71
+ deps: Deps.new(
72
+ manifest: @manifest,
73
+ reader: reader.method(:call),
74
+ lister: lister,
75
+ rpc: @rpc,
76
+ transform_context: @container,
77
+ ),
78
+ )
79
+ end
80
+
81
+ def self.pipeline_run(mentry:, deps:)
82
+ # 1. Load sources + project + reduce. Only projection-derived entries are
83
+ # buildable in-process; External entries are generated out-of-band and are
84
+ # filtered out upstream (Derived#publish_via), so reaching here with a
85
+ # non-projection source is a wiring bug — fail loudly rather than emit an
86
+ # empty payload (and never re-stamp the volatile generated_at, ADR 0070).
87
+ unless mentry.projection?
88
+ raise UsageError.new(
89
+ "builder: '#{mentry.key}' is not a projection-derived entry; only projections are buildable",
90
+ )
91
+ end
92
+
93
+ data =
94
+ Textus::Projection.new(
95
+ reader: deps.reader,
96
+ spec: mentry.source.projection_spec,
97
+ lister: deps.lister,
98
+ rpc: deps.rpc,
99
+ transform_context: deps.transform_context,
100
+ ).run
101
+
102
+ # 2. Serialize as DATA. Rendering through a template is a publish concern
103
+ # (ADR 0094) — the build never consults a template.
104
+ klass = renderers[mentry.format] or
105
+ raise UsageError.new("builder: unsupported data format #{mentry.format.inspect} for '#{mentry.key}'")
106
+ bytes = klass.new.call(mentry: mentry, data: data)
107
+
108
+ # 3. Write (idempotent: skip if only generated_at would differ)
109
+ target_path = Key::Path.resolve(deps.manifest.data, mentry)
110
+ FileUtils.mkdir_p(File.dirname(target_path))
111
+ write_if_changed(target_path, bytes, mentry.format)
112
+
113
+ target_path
114
+ end
115
+
116
+ # Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
117
+ # so identity is plain byte-equality: skip the write when nothing changed.
118
+ # `format` is retained for signature stability across renderers.
119
+ def self.write_if_changed(target_path, bytes, _format)
120
+ return if File.exist?(target_path) && File.binread(target_path) == bytes
121
+
122
+ File.binwrite(target_path, bytes)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,31 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ class Serializer
7
+ class Json < Serializer
8
+ def call(mentry:, data:)
9
+ content = default_shape(mentry, data)
10
+ final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
11
+ Entry.for_format("json").serialize(meta: {}, body: "", content: final)
12
+ end
13
+
14
+ private
15
+
16
+ def default_shape(mentry, data)
17
+ has_transform = mentry.projection? &&
18
+ mentry.source.transform
19
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
+ data
21
+ elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
+ { "entries" => data["entries"] }
23
+ else
24
+ data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ module Produce
3
+ module Acquire
4
+ class Serializer
5
+ class Text < Serializer
6
+ def call(mentry:, data:) # rubocop:disable Lint/UnusedMethodArgument
7
+ # Text format serializes data as plain-text. Rendering through a
8
+ # template is a publish concern (ADR 0094) — build emits data only.
9
+ body = data.is_a?(Hash) ? data.to_s : data.inspect
10
+ Entry.for_format("text").serialize(meta: {}, body: body)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ class Serializer
7
+ class Yaml < Serializer
8
+ def call(mentry:, data:)
9
+ content = default_shape(mentry, data)
10
+ final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
11
+ Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
12
+ end
13
+
14
+ private
15
+
16
+ def default_shape(mentry, data)
17
+ has_transform = mentry.projection? &&
18
+ mentry.source.transform
19
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
+ data
21
+ elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
+ { "entries" => data["entries"] }
23
+ else
24
+ data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Produce
3
+ module Acquire
4
+ # Abstract base for output serializers. Each concrete serializer owns
5
+ # producing the bytes for one manifest format (json/yaml/text).
6
+ # Rendering through a template is a publish concern (ADR 0094) — serializers
7
+ # here only serialize data; they take no arguments.
8
+ class Serializer
9
+ def call(mentry:, data:)
10
+ _ = mentry
11
+ _ = data
12
+ raise NotImplementedError.new("#{self.class.name}#call not implemented")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,95 @@
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 converge 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; the converge pass passes
14
+ # all-derived + stale-intake.
15
+ class Engine
16
+ # Locked + failure-isolated convergence — the entry point worker handlers
17
+ # call to materialize a key set (ADR 0093 / job-queue model). A held lock
18
+ # is a soft miss (an in-flight build/converge already produces fresh
19
+ # output); any other error is republished as :produce_failed and never
20
+ # raised at the caller (ADR 0087 §5 failure isolation, preserved).
21
+ def self.converge(container:, call:, keys:)
22
+ Textus::Ports::BuildLock.with(root: container.root) do
23
+ new(container: container, call: call).call(keys: keys)
24
+ end
25
+ rescue Textus::BuildInProgress
26
+ nil
27
+ rescue Textus::Error => e
28
+ container.events.publish(
29
+ :produce_failed,
30
+ ctx: Textus::Hooks::Context.for(container: container, call: call),
31
+ keys: keys, error: e.message
32
+ )
33
+ end
34
+
35
+ def initialize(container:, call:)
36
+ @container = container
37
+ @call = call
38
+ @manifest = container.manifest
39
+ end
40
+
41
+ # keys: the machine entry keys to converge. Returns
42
+ # { produced: [k...], skipped: [k...], failed: [{ "key"=>, "error"=> }...] }
43
+ def call(keys:)
44
+ build_call = build_actor_call
45
+ context = build_context(build_call)
46
+ out = { produced: [], skipped: [], failed: [] }
47
+
48
+ keys.each do |key|
49
+ produce_one(key, build_call, context, out)
50
+ rescue Textus::Error => e
51
+ out[:failed] << { "key" => key, "error" => e.message }
52
+ end
53
+ out
54
+ end
55
+
56
+ private
57
+
58
+ # Acquire is per-`from`; publish is one uniform entry point (publish_via)
59
+ # for every kind. The command emit-vs-skip falls out of publish-mode
60
+ # resolution (Publish::None when no targets), so there is no command branch.
61
+ def produce_one(key, build_call, context, out)
62
+ entry = @manifest.resolver.resolve(key).entry
63
+
64
+ if entry.intake?
65
+ Textus::Produce::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
66
+ entry.publish_via(context) # emit any targets
67
+ out[:produced] << key # a fetch is production
68
+ else
69
+ result = entry.publish_via(context) # derived builds inside; command publishes-or-None
70
+ result.nil? ? (out[:skipped] << key) : (out[:produced] << key)
71
+ end
72
+ end
73
+
74
+ def build_actor_call
75
+ build_role = @manifest.policy.actor_for("converge") or
76
+ raise Textus::UsageError.new(
77
+ "no role holds the 'converge' capability",
78
+ hint: "declare a role with `can: [converge]` in .textus/manifest.yaml",
79
+ )
80
+ Textus::Call.build(
81
+ role: build_role,
82
+ correlation_id: @call.correlation_id,
83
+ dry_run: @call.dry_run,
84
+ )
85
+ end
86
+
87
+ def build_context(call)
88
+ Textus::Manifest::Entry::Base::PublishContext.new(
89
+ container: @container, call: call,
90
+ reader: Textus::Read::Get.new(container: @container, call: call)
91
+ )
92
+ end
93
+ end
94
+ end
95
+ 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 converge + 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
  []