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,74 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Drains the job queue: lease a job, look up its handler in the registry, run
4
+ # it (as the job's stamped authority — wired in a later phase), then ack on
5
+ # success or fail (requeue/dead-letter) on a raise. `drain` runs until the
6
+ # queue is empty and returns a summary. Delivery is at-least-once.
7
+ class Worker
8
+ Summary = Struct.new(:completed, :failed, keyword_init: true)
9
+
10
+ # The standard convergence worker: the closed handler allow-list plus the
11
+ # lease TTL from worker_config. Both `drain` and `serve` build it this way.
12
+ def self.for(container:, queue:)
13
+ new(
14
+ queue: queue, registry: Textus::Jobs::Handlers.registry,
15
+ container: container, lease_ttl: container.manifest.data.worker_config[:lease_ttl]
16
+ )
17
+ end
18
+
19
+ def initialize(queue:, registry:, container:, lease_ttl: 60)
20
+ @queue = queue
21
+ @registry = registry
22
+ @container = container
23
+ @lease_ttl = lease_ttl
24
+ end
25
+
26
+ def drain(worker_id: "drain-#{Process.pid}")
27
+ completed = 0
28
+ failed = 0
29
+ loop do
30
+ leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
31
+ break unless leased
32
+
33
+ case run_one(leased)
34
+ when :completed then completed += 1
35
+ when :dead_lettered then failed += 1
36
+ # :requeued -> a transient failure; it re-leases on a later iteration
37
+ end
38
+ end
39
+ Summary.new(completed: completed, failed: failed)
40
+ end
41
+
42
+ def drain_pool(pool: 4)
43
+ summaries = []
44
+ mutex = Mutex.new
45
+ threads = Array.new(pool) do |i|
46
+ Thread.new do
47
+ s = drain(worker_id: "pool-#{Process.pid}-#{i}")
48
+ mutex.synchronize { summaries << s }
49
+ end
50
+ end
51
+ threads.each(&:join)
52
+ Summary.new(
53
+ completed: summaries.sum(&:completed),
54
+ failed: summaries.sum(&:failed),
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ # Returns :completed on ack, or the queue's failure verdict (:requeued |
61
+ # :dead_lettered) on a raise. A requeued job re-leases on the next loop
62
+ # iteration, so a transient failure still drains; only a dead-letter is a
63
+ # terminal failure that counts toward the summary.
64
+ def run_one(leased)
65
+ entry = @registry.lookup(leased.job.type)
66
+ entry.handler.call(job: leased.job, container: @container)
67
+ @queue.ack(leased)
68
+ :completed
69
+ rescue StandardError => e
70
+ @queue.fail(leased, error: e.message)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -13,7 +13,7 @@ module Textus
13
13
  DEFAULT_MAPPING = {
14
14
  Textus::Role::HUMAN => %w[author propose].freeze,
15
15
  Textus::Role::AGENT => %w[propose].freeze,
16
- Textus::Role::AUTOMATION => %w[fetch build].freeze,
16
+ Textus::Role::AUTOMATION => %w[converge].freeze,
17
17
  }.freeze
18
18
 
19
19
  # Returns { role_name => [verbs] }. When `roles:` is declared we use
@@ -10,10 +10,11 @@ module Textus
10
10
  # resolution, rules) lives on Manifest::Policy / Resolver / Rules.
11
11
  class Data
12
12
  AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
+ WORKER_DEFAULTS = { pool: 4, poll: 5, lease_ttl: 60, max_attempts: 3 }.freeze
13
14
 
14
15
  attr_reader :raw, :root, :entries, :declared_zone_kinds,
15
16
  :zone_descs, :zone_owners,
16
- :audit_config, :role_caps, :policy
17
+ :audit_config, :worker_config, :role_caps, :policy
17
18
 
18
19
  def self.validate_key!(key)
19
20
  raise UsageError.new("empty key") if key.nil? || key.empty?
@@ -47,10 +48,11 @@ module Textus
47
48
  # future `zone_owners.key?(name)` means "owner declared", not "zone exists".
48
49
  @zone_owners = Array(raw["zones"]).to_h { |z| [z["name"], z["owner"]] }.compact
49
50
  @audit_config = build_audit_config(raw)
51
+ @worker_config = build_worker_config(raw)
50
52
  @role_caps = Capabilities.resolve(raw["roles"])
51
53
  # Policy is constructed before entries because Entry validators
52
- # call `entry.in_generator_zone?(policy)` and similar helpers
53
- # that take Policy as an argument.
54
+ # use the entry's own `derived?` and similar helpers that call into
55
+ # Policy; Policy must exist before entries are built.
54
56
  @policy = Policy.new(self)
55
57
  @entries = build_entries(raw)
56
58
  validate_declared_keys!
@@ -67,6 +69,19 @@ module Textus
67
69
  }.freeze
68
70
  end
69
71
 
72
+ # Worker/queue tunables (ADR: job-queue execution model). All optional;
73
+ # the daemon (serve) and batch drain read these, falling back to defaults
74
+ # so a manifest with no `worker:` block runs the queue out of the box.
75
+ def build_worker_config(raw)
76
+ w = raw["worker"] || {}
77
+ {
78
+ pool: w["pool"] || WORKER_DEFAULTS[:pool],
79
+ poll: w["poll"] || WORKER_DEFAULTS[:poll],
80
+ lease_ttl: w["lease_ttl"] || WORKER_DEFAULTS[:lease_ttl],
81
+ max_attempts: w["max_attempts"] || WORKER_DEFAULTS[:max_attempts],
82
+ }.freeze
83
+ end
84
+
70
85
  def build_entries(raw)
71
86
  Array(raw["entries"]).map do |e|
72
87
  entry = Manifest::Entry::Parser.call(e)
@@ -2,10 +2,10 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Base < Entry
5
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_to
5
+ attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_targets
6
6
 
7
7
  # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
- def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_to: [])
8
+ def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_targets: [])
9
9
  @raw = raw
10
10
  @key = key
11
11
  @path = path
@@ -13,7 +13,7 @@ module Textus
13
13
  @schema = schema
14
14
  @owner = owner
15
15
  @format = format
16
- @publish_to = Array(publish_to)
16
+ @publish_targets = Array(publish_targets)
17
17
  end
18
18
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
19
19
 
@@ -23,28 +23,34 @@ module Textus
23
23
  raise UsageError.new("entry '#{@key}': #{e.message}")
24
24
  end
25
25
 
26
- def in_generator_zone?(policy) = policy.derived_zone?(@zone)
27
- def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
26
+ def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
28
27
 
29
28
  def nested? = false
30
29
  def derived? = false
31
30
  def intake? = false
32
31
  def leaf? = false
33
32
 
33
+ # Production traits. Default false on Base (a leaf/intake entry is neither
34
+ # an out-of-band command nor a projection); Produced overrides both from
35
+ # its source. Lets publish modes call these without a `respond_to?` guard.
36
+ def external? = false
37
+ def projection? = false
38
+
34
39
  # Whether git should track this entry's file. Default true; an entry
35
40
  # marked `tracked: false` in the manifest stays protocol-readable but is
36
41
  # listed in the generated `.gitignore` (ADR 0043). Cross-cutting, so it
37
42
  # reads from raw here rather than threading through every constructor.
38
43
  def tracked? = @raw["tracked"] != false
39
44
 
45
+ # Single source of truth is @publish_targets (ADR 0094). These
46
+ # derive the ADR-0049/0052 views the publish modes consume.
47
+ def publish_to = @publish_targets.select(&:to_target?).map(&:to)
48
+ def publish_tree = @publish_targets.find(&:tree_target?)&.tree
49
+
40
50
  # Nil stubs for cross-cutting optional attrs. Subclasses override the
41
51
  # ones they own. Validators and serializers can call these directly
42
52
  # without `respond_to?` guards.
43
- def template = nil
44
- def inject_boot = false # rubocop:disable Naming/PredicateMethod
45
- def provenance = true # rubocop:disable Naming/PredicateMethod
46
53
  def events = {}
47
- def publish_tree = nil
48
54
  def ignore = []
49
55
 
50
56
  # Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
@@ -69,6 +75,19 @@ module Textus
69
75
  events.publish(event, ctx: hook_context, **payload)
70
76
  end
71
77
 
78
+ # Read a named template from the store's templates/ directory.
79
+ # Raises TemplateError when the file doesn't exist.
80
+ def read_template(name)
81
+ path = File.join(container.root.to_s, "templates", name)
82
+ unless File.exist?(path)
83
+ raise Textus::TemplateError.new(
84
+ "template '#{name}' not found",
85
+ template_name: name,
86
+ )
87
+ end
88
+ File.read(path)
89
+ end
90
+
72
91
  private
73
92
 
74
93
  def scope_for_hooks
@@ -6,11 +6,10 @@ module Textus
6
6
  # Entry::Publish::* — Nested is just the value (attributes + ignore
7
7
  # predicate) those modes read.
8
8
  class Nested < Base
9
- attr_reader :publish_tree, :ignore
9
+ attr_reader :ignore
10
10
 
11
- def initialize(publish_tree: nil, ignore: nil, **rest)
11
+ def initialize(ignore: nil, **rest)
12
12
  super(**rest)
13
- @publish_tree = publish_tree
14
13
  @ignore = Array(ignore)
15
14
  end
16
15
 
@@ -24,8 +23,8 @@ module Textus
24
23
  KIND = :nested
25
24
 
26
25
  def self.from_raw(common, raw)
26
+ # publish_tree is derived from publish_targets (ADR 0094) via Base#publish_tree
27
27
  new(
28
- publish_tree: raw.dig("publish", "tree"), # ADR 0052: typed publish block
29
28
  ignore: raw["ignore"],
30
29
  **common,
31
30
  )
@@ -2,8 +2,6 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  module Parser
5
- COMPUTE_KINDS = %w[projection external].freeze
6
-
7
5
  def self.call(raw)
8
6
  key = raw["key"] or raise UsageError.new("manifest entry missing key")
9
7
  path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
@@ -11,6 +9,12 @@ module Textus
11
9
 
12
10
  raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
13
11
  kind = raw_kind.to_sym
12
+ if %i[derived intake].include?(kind)
13
+ raise BadManifest.new(
14
+ "entry '#{key}': kind: #{kind} was collapsed into `kind: produced` (ADR 0095) — " \
15
+ "the produce method is `source.from` (#{kind == :intake ? "handler" : "project|command"})",
16
+ )
17
+ end
14
18
  format = resolve_format(raw, path)
15
19
 
16
20
  common = {
@@ -18,10 +22,7 @@ module Textus
18
22
  key: key, path: path, zone: zone,
19
23
  schema: raw["schema"], owner: raw["owner"],
20
24
  format: format,
21
- # ADR 0052: publish config is one typed block; the internal
22
- # publish_to/publish_tree readers (the ADR 0049 modes) are sourced
23
- # from it (publish_to <- publish.to, publish_tree <- publish.tree).
24
- publish_to: raw.dig("publish", "to")
25
+ publish_targets: publish_targets(raw)
25
26
  }
26
27
 
27
28
  klass = Entry::REGISTRY[kind] or
@@ -29,26 +30,29 @@ module Textus
29
30
  klass.from_raw(common, raw)
30
31
  end
31
32
 
33
+ # ADR 0093: an entry's production block is the unified `source:`. Returns a
34
+ # Domain::Policy::Source; kind (intake/derived) is read from source.from.
32
35
  def self.parse_source(raw, key)
33
- compute = raw["compute"]
34
- raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:") if compute.nil?
36
+ block = raw["source"] or
37
+ raise BadManifest.new("entry '#{key}' requires a source: { from: project|handler|command, ... }")
35
38
 
36
- unless COMPUTE_KINDS.include?(compute["kind"])
37
- raise BadManifest.new(
38
- "entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
39
- )
40
- end
39
+ Textus::Domain::Policy::Source.new(block)
40
+ end
41
41
 
42
- if compute["kind"] == "projection"
43
- Entry::Derived::Projection.new(
44
- select: compute["select"],
45
- pluck: compute["pluck"],
46
- sort_by: compute["sort_by"],
47
- transform: compute["transform"],
42
+ # ADR 0094: `publish:` is a LIST of target objects — to-targets
43
+ # [{to, template?, inject_boot?}] and/or a tree-target [{tree}]. The
44
+ # ADR-0052 map forms ({to: []} / {tree: …}) are retired.
45
+ def self.publish_targets(raw)
46
+ block = raw["publish"]
47
+ return [] if block.nil?
48
+
49
+ unless block.is_a?(Array)
50
+ raise BadManifest.new(
51
+ "entry '#{raw["key"]}': `publish:` must be a list of targets " \
52
+ "[{to:, template:?} | {tree:}] (ADR 0094); the `publish: { … }` map form was retired",
48
53
  )
49
- else
50
- Entry::Derived::External.new(sources: compute["sources"], command: compute["command"])
51
54
  end
55
+ block.map { |t| Textus::Domain::Policy::PublishTarget.new(t) }
52
56
  end
53
57
 
54
58
  def self.resolve_format(raw, path)
@@ -0,0 +1,56 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ # A produced entry (ADR 0095) — anything with a `source:`. The produce
5
+ # method (intake/derived/external) is read from source.from; there is no
6
+ # separate kind for it. Merges the former Derived + Intake classes.
7
+ class Produced < Base
8
+ attr_reader :source, :events
9
+
10
+ def initialize(source:, events: {}, **rest)
11
+ super(**rest)
12
+ @source = source
13
+ @events = events || {}
14
+ end
15
+
16
+ def intake? = @source.kind == :intake
17
+ def derived? = @source.kind == :derived
18
+ def external? = @source.external?
19
+ def projection? = @source.projection?
20
+ def nested? = !!@raw["nested"]
21
+ def handler = @source.handler
22
+ def config = @source.config
23
+
24
+ KIND = :produced
25
+
26
+ # ADR 0094/0095: projection (from: project) sources build their DATA
27
+ # artifact here, then publish via the ONE shared mode (Publish::ToPaths).
28
+ # Intake bytes come from Produce::Acquire::Intake and command (external) bytes from the
29
+ # out-of-band runner — neither builds, but both still publish their
30
+ # existing store bytes through the same mode. A projection entry with no
31
+ # targets is a terminal data node: it produced data, so report :built
32
+ # even though nothing was emitted.
33
+ def publish_via(pctx, prefix: nil)
34
+ built = false
35
+ if projection?
36
+ Textus::Produce::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
37
+ built = true
38
+ pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
39
+ end
40
+
41
+ emitted = publish_mode.publish(pctx, prefix: prefix)
42
+ return emitted if emitted
43
+ return nil unless built
44
+
45
+ { kind: :built, value: { "key" => @key, "path" => Key::Path.resolve(pctx.manifest.data, self), "published_to" => [] } }
46
+ end
47
+
48
+ def self.from_raw(common, raw)
49
+ new(source: Parser.parse_source(raw, common[:key]), events: raw["events"] || {}, **common)
50
+ end
51
+
52
+ Entry::REGISTRY[KIND] = self
53
+ end
54
+ end
55
+ end
56
+ end
@@ -9,9 +9,10 @@ module Textus
9
9
  # shared shape — Tree always walks at `base` and honors `ignore` in the
10
10
  # prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
11
11
  class SubtreeMirror
12
- def initialize(entry, pctx)
13
- @entry = entry
14
- @pctx = pctx
12
+ def initialize(entry, pctx, publisher: Textus::Ports::Publisher.new)
13
+ @entry = entry
14
+ @pctx = pctx
15
+ @publisher = publisher
15
16
  end
16
17
 
17
18
  # base: store dir the entry owns — the root `ignored?` globs are
@@ -40,8 +41,8 @@ module Textus
40
41
  next nil if @entry.ignored?(relative(src, base))
41
42
 
42
43
  dst = File.join(target_dir, relative(src, walk_root))
43
- Textus::Ports::Publisher.publish(source: src, target: dst, store_root: @pctx.root)
44
- @pctx.emit(:file_published, key: key, envelope: envelope, source: src, target: dst)
44
+ @publisher.publish(source: src, target: dst, store_root: @pctx.root)
45
+ @pctx.emit(:entry_published, key: key, envelope: envelope, source: src, target: dst)
45
46
  { "key" => key, "source" => src, "target" => dst }
46
47
  end
47
48
  end
@@ -57,7 +58,7 @@ module Textus
57
58
  next nil if kept.include?(abs)
58
59
  next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
59
60
 
60
- Textus::Ports::Publisher.unpublish(target: managed, store_root: @pctx.root)
61
+ @publisher.unpublish(target: managed, store_root: @pctx.root)
61
62
  managed
62
63
  end
63
64
  end
@@ -1,24 +1,75 @@
1
+ require "tempfile"
2
+
1
3
  module Textus
2
4
  class Manifest
3
5
  class Entry
4
6
  module Publish
5
- # publish.to: copy the entry's one stored file to each fixed repo path.
6
- # The behaviour of any entry that declares `publish: { to: [...] }`.
7
+ # publish.to: render or copy the entry's stored data to each fixed repo path.
8
+ # The behaviour of any entry that declares `publish: [{ to: ... }, ...]`.
9
+ # ADR 0094: iterates publish_targets (to-targets), rendering through a
10
+ # template when the target declares one, or copying verbatim otherwise.
7
11
  class ToPaths < Mode
8
- def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
9
- targets = Array(entry.publish_to)
12
+ def initialize(entry, publisher: Textus::Ports::Publisher.new)
13
+ super(entry)
14
+ @publisher = publisher
15
+ end
16
+
17
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
18
+ targets = entry.publish_targets.select(&:to_target?)
10
19
  return nil if targets.empty?
11
20
 
12
- source_path = pctx.manifest.resolver.resolve(entry.key).path
13
- envelope = pctx.reader.call(entry.key)
21
+ data_path = pctx.manifest.resolver.resolve(entry.key).path
22
+ envelope = pctx.reader.call(entry.key)
23
+ renderer = Textus::Produce::Render.new(template_loader: ->(n) { pctx.read_template(n) })
24
+ content = nil # parsed lazily; the data's `content` (always _meta-free)
14
25
 
15
- targets.each do |rel|
16
- target_abs = File.join(pctx.repo_root, rel)
17
- Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
18
- pctx.emit(:file_published, key: entry.key, envelope: envelope, source: source_path, target: target_abs)
26
+ targets.each do |t|
27
+ if t.renders?
28
+ content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
29
+ publish_bytes(render_bytes(t, content, renderer, pctx), entry.key, t, pctx, data_path, envelope)
30
+ elsif strip_meta?(entry)
31
+ content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
32
+ bytes = Textus::Entry.for_format(entry.format).serialize(meta: {}, body: "", content: content)
33
+ publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
34
+ else
35
+ # opaque / command / non-structured — publish the stored file as-is
36
+ target_abs = File.join(pctx.repo_root, t.to)
37
+ @publisher.publish(source: data_path, target: target_abs, store_root: pctx.root)
38
+ pctx.emit(:entry_published, key: entry.key, envelope: envelope, source: data_path, target: target_abs)
39
+ end
19
40
  end
20
41
 
21
- { kind: :built, value: { "key" => entry.key, "path" => source_path, "published_to" => targets } }
42
+ { kind: :built, value: { "key" => entry.key, "path" => data_path, "published_to" => targets.map(&:to) } }
43
+ end
44
+
45
+ private
46
+
47
+ # A structured-data entry that textus owns: its `_meta` stays in the
48
+ # store, so the published file is the re-serialized meta-free content.
49
+ # An external (command) entry is opaque — never parse/re-serialize it.
50
+ def strip_meta?(entry)
51
+ !entry.external? && %w[json yaml].include?(entry.format.to_s)
52
+ end
53
+
54
+ def render_bytes(target, content, renderer, pctx)
55
+ boot = target.inject_boot ? Textus::Boot.build(container: pctx.container) : nil
56
+ renderer.bytes_for(target: target, data: content, boot: boot)
57
+ end
58
+
59
+ # Write bytes to a system temp, publish (recording the persistent data
60
+ # file as the sentinel source), then remove the temp — the store is
61
+ # never polluted with render artifacts.
62
+ def publish_bytes(bytes, key, target, pctx, data_path, envelope)
63
+ target_abs = File.join(pctx.repo_root, target.to)
64
+ Tempfile.create(["textus-publish", File.extname(target.to)]) do |f|
65
+ f.binmode
66
+ f.write(bytes)
67
+ f.flush
68
+ @publisher.publish(
69
+ source: f.path, target: target_abs, store_root: pctx.root, provenance_source: data_path,
70
+ )
71
+ end
72
+ pctx.emit(:entry_published, key: key, envelope: envelope, source: data_path, target: target_abs)
22
73
  end
23
74
  end
24
75
  end
@@ -3,24 +3,16 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  module FormatMatrix
6
- def self.call(entry, policy:)
6
+ def self.call(entry, policy:) # rubocop:disable Lint/UnusedMethodArgument
7
7
  begin
8
8
  Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
9
9
  rescue UsageError => e
10
10
  raise UsageError.new("entry '#{entry.key}': #{e.message}")
11
11
  end
12
12
 
13
- if entry.format == "text" && !entry.schema.nil?
14
- raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
15
- end
16
-
17
- has_template = !entry.template.nil?
18
- is_external = entry.derived? && entry.external?
19
- is_intake = entry.intake?
20
- return unless entry.in_generator_zone?(policy) && !has_template && !is_external && !is_intake &&
21
- %w[markdown text].include?(entry.format) && !entry.nested?
13
+ return unless entry.format == "text" && !entry.schema.nil?
22
14
 
23
- raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
15
+ raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
24
16
  end
25
17
  end
26
18
  end
@@ -14,7 +14,9 @@ module Textus
14
14
  module Publish
15
15
  def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
16
16
  unless entry.nested?
17
- raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.raw.dig("publish", "tree")
17
+ # ADR 0094: publish: is now a list; use publish_tree (derived reader)
18
+ # rather than raw.dig("publish", "tree") which breaks on an Array.
19
+ raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.publish_tree
18
20
 
19
21
  return
20
22
  end
@@ -5,7 +5,6 @@ module Textus
5
5
  REGISTERED = [
6
6
  Events,
7
7
  Publish,
8
- InjectBoot,
9
8
  Ignore,
10
9
  FormatMatrix,
11
10
  ].freeze
@@ -7,7 +7,7 @@ module Textus
7
7
  # (Schema::KIND_REQUIRES_VERB) and a role may write a zone iff its caps
8
8
  # include that verb (verb_for_zone, roles_with_capability). Derived /
9
9
  # proposal-queue status is authoritative via the declared-kind family
10
- # (declared_kind, derived_zone?, queue_zone?, queue_zone).
10
+ # (declared_kind, derived_entry?, queue_zone?, queue_zone).
11
11
  class Policy
12
12
  def initialize(data)
13
13
  @data = data
@@ -72,9 +72,21 @@ module Textus
72
72
  @data.declared_zone_kinds.key(:queue)
73
73
  end
74
74
 
75
- # A zone is derived iff it declares kind: derived.
76
- def derived_zone?(zone_name)
77
- declared_kind(zone_name) == :derived
75
+ # ADR 0091: derived-ness is a property of the ENTRY, not its zone (one
76
+ # machine zone holds both intake and derived entries). Resolve the entry
77
+ # and ask it directly. Returns false if entries are not yet built
78
+ # (validator phase during Data#initialize) — validators must not rely on
79
+ # cross-entry state during construction.
80
+ def derived_entry?(key)
81
+ return false if @data.entries.nil?
82
+
83
+ entry = @data.entries.find { |e| e.key == key } or return false
84
+ entry.derived?
85
+ end
86
+
87
+ # The single zone declaring kind: machine, or nil.
88
+ def machine_zone
89
+ @data.declared_zone_kinds.key(:machine)
78
90
  end
79
91
 
80
92
  # A zone is a proposal queue iff it declares kind: queue.
@@ -30,8 +30,10 @@ module Textus
30
30
  []
31
31
  end
32
32
 
33
- def enumerate(prefix: nil)
34
- out = @data.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
33
+ def enumerate(prefix: nil, include_keyless: false)
34
+ out = @data.entries.flat_map do |entry|
35
+ nested_entry?(entry) ? enumerate_nested(entry, include_keyless: include_keyless) : enumerate_leaf(entry)
36
+ end
35
37
  out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
36
38
  out.sort_by { |row| row[:key] }
37
39
  end
@@ -62,10 +64,14 @@ module Textus
62
64
  File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
63
65
  end
64
66
 
65
- def enumerate_nested(entry)
67
+ def enumerate_nested(entry, include_keyless: false)
66
68
  # publish_tree mirrors opaque payload by path — its files are never
67
69
  # enumerated as keys (ADR 0047). Ask the resolved mode, not the path.
68
- return [] if entry.publish_mode.keyless?
70
+ # The `include_keyless:` override is used only by the projection lister
71
+ # so that `from: project` selects can read source data from keyless
72
+ # nested entries (e.g. knowledge.decisions) without exposing them as
73
+ # addressable store keys in the public `list` surface.
74
+ return [] if entry.publish_mode.keyless? && !include_keyless
69
75
 
70
76
  base = File.join(@data.root, "zones", entry.path)
71
77
  return [] unless File.directory?(base)