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
@@ -28,16 +28,11 @@ module Textus
28
28
  @scope
29
29
  end
30
30
 
31
- # read — a deliberately pure-observation surface: NOTHING here fetches
32
- # (`list`/`deps`/`freshness` don't either). The invariant is that a hook
33
- # observes current state and never triggers an I/O cascade. `get` bypasses
34
- # the read-through behavior (ADR 0062) and reads with fetch:false directly,
35
- # because read-through inside a hook would: (1) fire fetch events → hooks →
36
- # unbounded reentrancy; (2) spawn the orchestrator's threads/fork from
37
- # inside a hook callback; (3) probe the single-flight fetch lock its own
38
- # enclosing fetch may hold (deadlock); (4) inject network latency into
39
- # every hook read. With the merged Read::Get class, `fetch:false` (the
40
- # method default) guarantees no orchestrator is built.
31
+ # read — a pure-observation surface: nothing here ingests. Since ADR 0089
32
+ # `get` itself is a pure read (the read-through that once forced this
33
+ # surface to opt out is gone, so the old re-entrancy/deadlock guard is no
34
+ # longer needed); `list`/`deps`/`freshness` are reads too. A hook observes
35
+ # current state and never triggers an I/O cascade.
41
36
  def get(key) = pure_reader.call(key)
42
37
  def list(**) = @scope.list(**)
43
38
  def deps(key) = @scope.deps(key)
@@ -1,15 +1,15 @@
1
1
  # .textus/hooks/machine_intake.rb
2
2
  # Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
3
3
  # entry from manifest.yaml if you don't want it.
4
- # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus fetch` (never
5
- # on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
4
+ # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus reconcile`
5
+ # (never on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
6
6
  # `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
7
7
  # (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
8
8
  # ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
9
9
  Textus.hook do |reg|
10
- reg.on(:resolve_intake, :machines) do |config:, args:, **|
10
+ reg.on(:resolve_handler, :machines) do |config:, args:, **|
11
11
  machine = args[:leaf_segments].first or
12
- raise "fetch a host leaf, e.g. `textus fetch feeds.machines.local`"
12
+ raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
13
13
  spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
14
14
  unless (spec["via"] || "local").to_s == "local"
15
15
  raise "machine #{machine}: only `via: local` is scaffolded — see " \
data/lib/textus/init.rb CHANGED
@@ -3,45 +3,44 @@ require "pathname"
3
3
 
4
4
  module Textus
5
5
  module Init
6
- ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
6
+ ZONES = %w[knowledge notebook proposals artifacts].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
9
  version: textus/3
10
10
  roles:
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
13
- - { name: automation, can: [fetch, build] }
13
+ - { name: automation, can: [reconcile] }
14
14
  zones:
15
15
  - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
16
16
  - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
17
- - { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
18
17
  - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
19
- - { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
18
+ - { name: artifacts, kind: machine, desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
20
19
  entries:
21
20
  - { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
22
21
  - { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
23
22
  - { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
24
23
  - { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
25
- # A per-host snapshot, pulled by `textus fetch feeds.machines.local --as=automation`.
26
- # Nested so it grows to a fleet — add feeds.machines.<host> leaves over SSH
24
+ # A per-host snapshot, refreshed from its declared intake by `textus reconcile` (scheduled, or on demand).
25
+ # Nested so it grows to a fleet — add artifacts.feeds.machines.<host> leaves over SSH
27
26
  # (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
28
27
  # gitignored (machine info can be sensitive/noisy) but still protocol-readable
29
- # via `textus get feeds.machines.local`. Delete to opt out. (ADR 0043)
30
- - key: feeds.machines
31
- path: feeds/machines
32
- zone: feeds
28
+ # via `textus get artifacts.feeds.machines.local`. Delete to opt out. (ADR 0043)
29
+ - key: artifacts.feeds.machines
30
+ path: artifacts/feeds/machines
31
+ zone: artifacts
33
32
  format: yaml
34
33
  nested: true
35
34
  tracked: false
36
- kind: intake
37
- intake:
35
+ kind: produced
36
+ source:
37
+ from: handler
38
38
  handler: machines
39
+ ttl: 1h # cadence on a long-running server
39
40
  config:
40
41
  machines:
41
42
  local: { via: local }
42
- rules:
43
- - match: feeds.machines.**
44
- lifecycle: { ttl: 1h, on_expire: warn } # meaningful on a long-running server
43
+ rules: []
45
44
  YAML
46
45
 
47
46
  HOOKS_README = <<~MD
@@ -56,71 +55,72 @@ module Textus
56
55
 
57
56
  ```ruby
58
57
  Textus.hook do |reg|
59
- reg.on(:resolve_intake, :my_source) do |config:, args:, **|
58
+ reg.on(:resolve_handler, :my_source) do |config:, args:, **|
60
59
  { _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
61
60
  end
62
61
 
63
62
  reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
64
63
  reg.on(:validate, :my_check) { |caps:, **| [] }
65
- reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
64
+ reg.on(:entry_written, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
66
65
 
67
66
  # Run a side-effect every time textus writes a file to your repo:
68
- reg.on(:file_published, :notify) do |key:, target:, **|
67
+ reg.on(:entry_published, :notify) do |key:, target:, **|
69
68
  warn "wrote \#{target} (from \#{key})"
70
69
  end
71
70
  end
72
71
  ```
73
72
 
74
- The intake handler above is paired with a manifest entry plus a
75
- top-level `rules:` block for lifecycle (ttl/on_expire live in
76
- rules, not in the entry):
73
+ The intake handler above is paired with a manifest entry whose
74
+ `source:` block declares the handler and its refresh cadence
75
+ (`ttl`). Age GC (drop/archive) lives in a top-level `retention:`
76
+ rule, not on the entry:
77
77
 
78
78
  ```yaml
79
79
  entries:
80
- - key: feeds.foo
81
- kind: intake
82
- path: feeds/foo.md
83
- zone: feeds
84
- intake:
80
+ - key: artifacts.feeds.foo
81
+ kind: produced
82
+ path: artifacts/feeds/foo.md
83
+ zone: artifacts
84
+ source:
85
+ from: handler
85
86
  handler: my_source
87
+ ttl: 10m # refresh cadence for this intake
86
88
 
87
89
  rules:
88
- - match: feeds.foo
89
- lifecycle:
90
- ttl: 10m
91
- on_expire: refresh # refresh | warn (intake); drop | archive (stored)
90
+ - match: artifacts.feeds.foo
91
+ retention:
92
+ ttl: 30d
93
+ action: archive # drop | archive (age GC of stored rows)
92
94
  ```
93
95
 
94
- Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
95
- :entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
96
- :build_completed, :proposal_accepted, :proposal_rejected,
97
- :file_published, :store_loaded, :session_opened,
98
- :fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
96
+ Events: :resolve_handler, :transform_rows, :validate (rpc — return value used)
97
+ :entry_written, :entry_deleted, :entry_fetched, :entry_renamed,
98
+ :entry_produced, :produce_failed, :reconcile_failed,
99
+ :proposal_accepted, :proposal_rejected,
100
+ :entry_published, :store_loaded, :session_opened,
101
+ :entry_fetch_started, :entry_fetch_failed (pub-sub — return discarded)
99
102
 
100
103
  See SPEC.md §5.10 for the full table.
101
104
  MD
102
105
 
103
106
  AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
104
107
  # --with-agent profile: project facts + runbooks feed the orientation
105
- # projection below, which `textus build` renders to CLAUDE.md/AGENTS.md.
108
+ # projection below, which `textus reconcile` renders to CLAUDE.md/AGENTS.md.
106
109
  - { key: knowledge.project, path: knowledge/project.md, zone: knowledge, schema: project, owner: human:self, kind: leaf }
107
110
  - { key: knowledge.runbooks, path: knowledge/runbooks, zone: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
108
- - key: artifacts.orientation
109
- path: artifacts/orientation.md
111
+ - key: artifacts.derived.orientation
112
+ path: artifacts/derived/orientation.json
110
113
  zone: artifacts
111
- template: orientation.mustache
112
- inject_boot: true
113
114
  publish:
114
- to:
115
- - CLAUDE.md
116
- - AGENTS.md
117
- compute:
118
- kind: projection
115
+ - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
116
+ - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
117
+ source:
118
+ from: project
119
119
  select:
120
120
  - knowledge.project
121
121
  - knowledge.runbooks
122
122
  transform: orientation_reducer
123
- kind: derived
123
+ kind: produced
124
124
  YAML
125
125
 
126
126
  def self.run(target_root, with_agent: false)
@@ -193,7 +193,7 @@ module Textus
193
193
  manifest = Textus::Manifest.load(target_root)
194
194
  root = Pathname.new(target_root)
195
195
  untracked = manifest.data.entries.reject(&:tracked?).map do |e|
196
- if e.nested? # a whole subtree of leaf files (feeds.machines.* → zones/feeds/machines/)
196
+ if e.nested? # a whole subtree of leaf files (artifacts.feeds.machines.* → zones/artifacts/feeds/machines/)
197
197
  "#{File.join("zones", e.path)}/"
198
198
  else
199
199
  Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Key
3
+ # Dotted-key scope matching, shared by all prefix-scoped sweeps
4
+ # (WS4 / ADR 0089-era cleanup). Canonicalised here so every consumer
5
+ # uses a consistent dotted-boundary check with proper Nested ancestor
6
+ # handling. ADR 0093: Produce is the sole engine calling this.
7
+ module Matching
8
+ module_function
9
+
10
+ # Is `key` within the `prefix` scope?
11
+ # - exact match, or a dotted descendant (the `prefix.` boundary, so
12
+ # prefix "art" does NOT match key "artifacts"), and
13
+ # - for a nested entry, also when `prefix` descends INTO it — the nested
14
+ # parent owns the leaf the prefix names (e.g. prefix
15
+ # "feeds.machines.host1" still selects the nested entry
16
+ # "feeds.machines").
17
+ def matches_prefix?(key, prefix, nested: false)
18
+ return true if key == prefix || key.start_with?("#{prefix}.")
19
+
20
+ nested && prefix.start_with?("#{key}.")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,160 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ # Two-phase convergence pass (ADR 0093). Replaces the old Lifecycle-reporter
6
+ # sweep.
7
+ #
8
+ # Phase 1 — Produce (non-destructive): re-render ALL derived entries (cheap,
9
+ # idempotent) plus every intake entry past its source.ttl (stale-only, so
10
+ # external sources are not hammered). Driven by Produce::Engine.
11
+ #
12
+ # Phase 2 — Retention sweep (destructive): drop or archive entries past their
13
+ # retention ttl. Driven by Domain::Retention::Sweep. The old refresh/warn
14
+ # actions are gone — intake re-pull is now Produce's responsibility.
15
+ class Reconcile
16
+ extend Textus::Contract::DSL
17
+
18
+ verb :reconcile
19
+ summary "Run the convergence pass: produce derived + stale intake, then drop/archive aged entries; report health."
20
+ surfaces :cli, :mcp
21
+ cli "reconcile"
22
+ arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
23
+ arg :zone, String, description: "restrict the sweep to entries in this zone"
24
+ arg :dry_run, :boolean, default: false,
25
+ description: "when true, report what the pass WOULD do without applying; " \
26
+ "defaults to false, so omitting it produces + drops/archives immediately"
27
+
28
+ def initialize(container:, call:)
29
+ @container = container
30
+ @call = call
31
+ end
32
+
33
+ def call(prefix: nil, zone: nil, dry_run: false)
34
+ file_stat = Textus::Ports::Storage::FileStat.new
35
+ retention_rows = Textus::Domain::Retention::Sweep.new(
36
+ manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
37
+ ).call(prefix: prefix, zone: zone)
38
+
39
+ produce_keys = produce_scope(prefix, zone, file_stat)
40
+ health = Read::Doctor.new(container: @container, call: @call).call
41
+ return dry_run_result(produce_keys, retention_rows, health) if dry_run
42
+
43
+ # reconcile is the authoritative "make everything current now" pass, so
44
+ # it subsumes any in-flight reactive produce: drain pending async
45
+ # produce-on-write threads first, both to fold their work in and to free
46
+ # the shared maintenance lock (BuildLock is non-blocking — a thread still
47
+ # holding it would make the acquire below raise BuildInProgress). ADR 0093.
48
+ Textus::Produce::Engine::AsyncRunner.drain
49
+
50
+ Textus::Ports::BuildLock.with(root: @container.root) do
51
+ produced = Textus::Produce::Engine.new(container: @container, call: @call).call(keys: produce_keys)
52
+ swept = apply(retention_rows)
53
+ publish_failed(swept[:failed]) unless swept[:failed].empty?
54
+ apply_result(produced, swept, health)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # The full produce scope (ADR 0093): every derived entry (always
61
+ # re-render — cheap, idempotent), every entry that mirrors a publish_tree
62
+ # (the nested-subtree publishers, ADR 0047 — mirrored each pass so a
63
+ # removed source leaf is swept from the published tree), every authored
64
+ # leaf with a `publish.to` target (the single-file canon publishers —
65
+ # docs/README.md, the architecture index, the root README; ADR 0103 —
66
+ # converged each pass so a stale published copy is rewritten and the
67
+ # `reconcile`-is-a-no-op check guards them), plus every intake entry past
68
+ # its source.ttl (re-pull only when due, so external sources aren't
69
+ # hammered). Ttl-less intake entries (:no_policy) are skipped — they have
70
+ # no freshness contract and are never auto-re-pulled (ADR 0099). All are
71
+ # idempotent: publish writes only when the target's content changed.
72
+ def produce_scope(prefix, zone, file_stat)
73
+ publishable = @container.manifest.data.entries
74
+ .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
75
+ .select { |e| in_scope?(e, prefix, zone) }.map(&:key)
76
+ stale_intake = Textus::Domain::Freshness::Evaluator.new(
77
+ manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
78
+ ).stale_intake_keys(prefix: prefix, zone: zone)
79
+ (publishable + stale_intake).uniq
80
+ end
81
+
82
+ def in_scope?(entry, prefix, zone)
83
+ return false if zone && entry.zone != zone
84
+ return false if prefix && !entry.key.start_with?(prefix)
85
+
86
+ true
87
+ end
88
+
89
+ def dry_run_result(produce_keys, rows, health)
90
+ {
91
+ "protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
92
+ "would_produce" => produce_keys,
93
+ "would_drop" => action_keys(rows, "drop"),
94
+ "would_archive" => action_keys(rows, "archive"),
95
+ "health" => health
96
+ }
97
+ end
98
+
99
+ def apply_result(produced, swept, health)
100
+ {
101
+ "protocol" => Textus::PROTOCOL,
102
+ "ok" => produced[:failed].empty? && swept[:failed].empty?,
103
+ "dry_run" => false,
104
+ "produced" => produced[:produced],
105
+ "produce_failed" => produced[:failed],
106
+ "dropped" => swept[:dropped], "archived" => swept[:archived],
107
+ "failed" => swept[:failed],
108
+ "health" => health
109
+ }
110
+ end
111
+
112
+ def action_keys(rows, action)
113
+ rows.select { |r| r["action"] == action }.map { |r| r["key"] }
114
+ end
115
+
116
+ def publish_failed(failed)
117
+ @container.events.publish(
118
+ :reconcile_failed,
119
+ ctx: Textus::Hooks::Context.for(container: @container, call: @call),
120
+ failed: failed,
121
+ )
122
+ end
123
+
124
+ # Phase 2: destructive retention only (drop/archive). No refresh — intake
125
+ # re-pull is Produce's job (Phase 1). ADR 0093.
126
+ def apply(rows)
127
+ out = { dropped: [], archived: [], failed: [] }
128
+ delete = Write::KeyDelete.new(container: @container, call: @call)
129
+ rows.each do |row|
130
+ key = row["key"]
131
+ begin
132
+ case row["action"]
133
+ when "drop"
134
+ delete.call(key)
135
+ out[:dropped] << key
136
+ when "archive"
137
+ archive_leaf(row)
138
+ delete.call(key)
139
+ out[:archived] << key
140
+ end
141
+ rescue Textus::Error => e
142
+ out[:failed] << { "key" => key, "error" => e.message }
143
+ end
144
+ end
145
+ out
146
+ end
147
+
148
+ # Copy the leaf into <store>/archive/<relative-path> before deletion.
149
+ # (Lifted from the retired RetentionSweep#archive_leaf.)
150
+ def archive_leaf(row)
151
+ src = row["path"]
152
+ root = @container.root.to_s
153
+ rel = src.delete_prefix("#{root}/")
154
+ dest = File.join(root, "archive", rel)
155
+ FileUtils.mkdir_p(File.dirname(dest))
156
+ FileUtils.cp(src, dest)
157
+ end
158
+ end
159
+ end
160
+ 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[reconcile].freeze,
17
17
  }.freeze
18
18
 
19
19
  # Returns { role_name => [verbs] }. When `roles:` is declared we use
@@ -49,8 +49,8 @@ module Textus
49
49
  @audit_config = build_audit_config(raw)
50
50
  @role_caps = Capabilities.resolve(raw["roles"])
51
51
  # 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.
52
+ # use the entry's own `derived?` and similar helpers that call into
53
+ # Policy; Policy must exist before entries are built.
54
54
  @policy = Policy.new(self)
55
55
  @entries = build_entries(raw)
56
56
  validate_declared_keys!
@@ -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)