textus 0.8.1 → 0.9.2

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +224 -0
  3. data/README.md +50 -22
  4. data/SPEC.md +194 -63
  5. data/docs/architecture.md +22 -4
  6. data/docs/conventions.md +24 -17
  7. data/lib/textus/application/context.rb +68 -0
  8. data/lib/textus/application/reads/audit.rb +69 -0
  9. data/lib/textus/application/reads/blame.rb +79 -0
  10. data/lib/textus/application/reads/freshness.rb +77 -0
  11. data/lib/textus/application/reads/get.rb +62 -0
  12. data/lib/textus/application/reads/policy_explain.rb +39 -0
  13. data/lib/textus/application/refresh/all.rb +41 -0
  14. data/lib/textus/application/refresh/orchestrator.rb +68 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +43 -0
  17. data/lib/textus/application/writes/build.rb +24 -0
  18. data/lib/textus/application/writes/delete.rb +37 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +44 -0
  21. data/lib/textus/builder.rb +27 -14
  22. data/lib/textus/cli/group/policy.rb +11 -0
  23. data/lib/textus/cli/verb/accept.rb +2 -1
  24. data/lib/textus/cli/verb/audit.rb +31 -0
  25. data/lib/textus/cli/verb/blame.rb +17 -0
  26. data/lib/textus/cli/verb/build.rb +2 -1
  27. data/lib/textus/cli/verb/delete.rb +2 -1
  28. data/lib/textus/cli/verb/freshness.rb +17 -0
  29. data/lib/textus/cli/verb/get.rb +8 -1
  30. data/lib/textus/cli/verb/hook_run.rb +3 -3
  31. data/lib/textus/cli/verb/policy_explain.rb +15 -0
  32. data/lib/textus/cli/verb/policy_list.rb +25 -0
  33. data/lib/textus/cli/verb/put.rb +5 -4
  34. data/lib/textus/cli/verb/refresh.rb +2 -1
  35. data/lib/textus/cli/verb/refresh_stale.rb +19 -0
  36. data/lib/textus/cli/verb/reject.rb +15 -0
  37. data/lib/textus/cli.rb +16 -2
  38. data/lib/textus/composition.rb +71 -0
  39. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  40. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  41. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  42. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  43. data/lib/textus/doctor.rb +4 -0
  44. data/lib/textus/domain/action.rb +9 -0
  45. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  46. data/lib/textus/domain/freshness/policy.rb +18 -0
  47. data/lib/textus/domain/freshness/verdict.rb +12 -0
  48. data/lib/textus/domain/outcome.rb +10 -0
  49. data/lib/textus/domain/permission.rb +15 -0
  50. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  51. data/lib/textus/domain/policy/matcher.rb +51 -0
  52. data/lib/textus/domain/policy/promote.rb +24 -0
  53. data/lib/textus/domain/policy/refresh.rb +48 -0
  54. data/lib/textus/domain/policy.rb +7 -0
  55. data/lib/textus/hooks/builtin.rb +5 -5
  56. data/lib/textus/hooks/dispatcher.rb +15 -1
  57. data/lib/textus/hooks/dsl.rb +18 -0
  58. data/lib/textus/hooks/registry.rb +12 -5
  59. data/lib/textus/infra/clock.rb +9 -0
  60. data/lib/textus/infra/event_bus.rb +27 -0
  61. data/lib/textus/infra/publisher.rb +73 -0
  62. data/lib/textus/infra/refresh/detached.rb +38 -0
  63. data/lib/textus/infra/refresh/lock.rb +44 -0
  64. data/lib/textus/init.rb +71 -28
  65. data/lib/textus/intro.rb +19 -11
  66. data/lib/textus/manifest/entry.rb +18 -9
  67. data/lib/textus/manifest/policies.rb +83 -0
  68. data/lib/textus/manifest.rb +30 -0
  69. data/lib/textus/proposal.rb +4 -21
  70. data/lib/textus/publisher.rb +4 -69
  71. data/lib/textus/refresh.rb +9 -44
  72. data/lib/textus/store/mover.rb +14 -9
  73. data/lib/textus/store/reader.rb +10 -8
  74. data/lib/textus/store/staleness.rb +4 -16
  75. data/lib/textus/store/validator.rb +46 -20
  76. data/lib/textus/store/view.rb +8 -19
  77. data/lib/textus/store/writer.rb +51 -14
  78. data/lib/textus/store.rb +29 -9
  79. data/lib/textus/version.rb +1 -1
  80. data/lib/textus.rb +1 -0
  81. metadata +46 -2
  82. data/lib/textus/cli/verb/stale.rb +0 -14
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Publish
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(source:, target:, key:)
11
+ Textus::Infra::Publisher.publish(
12
+ source: source,
13
+ target: target,
14
+ store_root: @ctx.store.root,
15
+ )
16
+ @bus.publish(:published,
17
+ key: key,
18
+ source: source,
19
+ target: target,
20
+ correlation_id: @ctx.correlation_id)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Put
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(key, meta: nil, body: nil, content: nil, if_etag: nil, suppress_events: false)
11
+ @ctx.store.manifest.validate_key!(key)
12
+ mentry, = @ctx.store.manifest.resolve(key)
13
+
14
+ unless @ctx.can_write?(mentry.zone)
15
+ raise WriteForbidden.new(key, mentry.zone,
16
+ writers: @ctx.store.manifest.zone_writers(mentry.zone))
17
+ end
18
+
19
+ envelope = @ctx.store.writer.write_envelope_to_disk(
20
+ key,
21
+ mentry: mentry,
22
+ meta: meta,
23
+ body: body,
24
+ content: content,
25
+ if_etag: if_etag,
26
+ as: @ctx.role,
27
+ correlation_id: @ctx.correlation_id,
28
+ )
29
+
30
+ unless suppress_events
31
+ store_view = Store::View.new(@ctx.store)
32
+ @bus.publish(:put,
33
+ store: store_view,
34
+ key: key,
35
+ envelope: envelope,
36
+ correlation_id: @ctx.correlation_id)
37
+ end
38
+
39
+ envelope
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,8 @@
1
1
  require "fileutils"
2
2
 
3
+ # As of 0.9.1, Textus::Application::Writes::Build is the preferred public
4
+ # entry point. This class remains as the implementation home of materialization
5
+ # and projection logic; full extraction is deferred to 0.10.0.
3
6
  module Textus
4
7
  class Builder
5
8
  def initialize(store)
@@ -35,21 +38,27 @@ module Textus
35
38
  next unless row[:manifest_entry].equal?(mentry)
36
39
  next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
37
40
 
38
- target_rel = mentry.publish_target_for(row[:key])
39
- target_abs = File.expand_path(File.join(repo_root, target_rel))
40
- unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
41
- raise PublishError.new(
42
- "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
43
- )
44
- end
45
-
46
- Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
47
- out << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
41
+ out << publish_leaf(mentry, row, repo_root)
48
42
  end
49
43
  end
50
44
  out
51
45
  end
52
46
 
47
+ def publish_leaf(mentry, row, repo_root)
48
+ target_rel = mentry.publish_target_for(row[:key])
49
+ target_abs = File.expand_path(File.join(repo_root, target_rel))
50
+ unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
51
+ raise PublishError.new(
52
+ "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
53
+ )
54
+ end
55
+
56
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
57
+ @store.fire_event(:published, key: row[:key], envelope: @store.get(row[:key]),
58
+ source: row[:path], target: target_abs)
59
+ { "key" => row[:key], "source" => row[:path], "target" => target_abs }
60
+ end
61
+
53
62
  def derived_zone?(mentry)
54
63
  writers = @manifest.zone_writers(mentry.zone)
55
64
  writers.include?("build")
@@ -73,13 +82,17 @@ module Textus
73
82
  end
74
83
 
75
84
  def publish_and_fire(mentry, target_path)
85
+ envelope = @store.get(mentry.key)
86
+ repo_root = File.dirname(@root)
87
+
76
88
  mentry.publish_to.each do |rel|
77
- repo_root = File.dirname(@root)
78
- Publisher.publish(source: target_path, target: File.join(repo_root, rel), store_root: @root)
89
+ target_abs = File.join(repo_root, rel)
90
+ Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
91
+ @store.fire_event(:published, key: mentry.key, envelope: envelope,
92
+ source: target_path, target: target_abs)
79
93
  end
80
94
 
81
- envelope = @store.get(mentry.key)
82
- @store.fire_event(:build, key: mentry.key, envelope: envelope,
95
+ @store.fire_event(:built, key: mentry.key, envelope: envelope,
83
96
  sources: Array(mentry.projection&.fetch("select", nil)).compact)
84
97
  end
85
98
  end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Group
4
+ class Policy < Group
5
+ self.cli_name = "policy"
6
+ subcommands["list"] = Verb::PolicyList
7
+ subcommands["explain"] = Verb::PolicyExplain
8
+ end
9
+ end
10
+ end
11
+ end
@@ -7,7 +7,8 @@ module Textus
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("accept requires a key")
9
9
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- emit(store.accept(key, as: role))
10
+ ctx = Textus::Composition.context(store, role: role)
11
+ emit(Textus::Composition.writes_accept(ctx).call(key))
11
12
  end
12
13
  end
13
14
  end
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Audit < Verb
5
+ option :key_filter, "--key=KEY"
6
+ option :zone, "--zone=Z"
7
+ option :role_filter, "--role=ROLE"
8
+ option :verb_filter, "--verb=V"
9
+ option :since, "--since=ISO8601|RELATIVE"
10
+ option :correlation_id, "--correlation-id=ID"
11
+ option :limit, "--limit=N"
12
+
13
+ def call(store)
14
+ role = Role.resolve(flag: nil, env: ENV, root: store.root)
15
+ ctx = Textus::Composition.context(store, role: role)
16
+ since_time = since && Textus::Application::Reads::Audit.parse_since(since, now: ctx.now)
17
+ rows = Textus::Composition.audit(ctx).call(
18
+ key: key_filter,
19
+ zone: zone,
20
+ role: role_filter,
21
+ verb: verb_filter,
22
+ since: since_time,
23
+ correlation_id: correlation_id,
24
+ limit: limit&.to_i,
25
+ )
26
+ emit({ "verb" => "audit", "rows" => rows })
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Blame < Verb
5
+ option :limit, "--limit=N"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("blame requires a key")
9
+ role = Role.resolve(flag: nil, env: ENV, root: store.root)
10
+ ctx = Textus::Composition.context(store, role: role)
11
+ rows = Textus::Composition.blame(ctx).call(key: key, limit: limit&.to_i)
12
+ emit({ "verb" => "blame", "key" => key, "rows" => rows })
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -5,7 +5,8 @@ module Textus
5
5
  option :prefix, "--prefix=K"
6
6
 
7
7
  def call(store)
8
- emit(Textus::Builder.new(store).build(prefix: prefix))
8
+ ctx = Textus::Composition.context(store, role: "build")
9
+ emit(Textus::Composition.writes_build(ctx).call(prefix: prefix))
9
10
  end
10
11
  end
11
12
  end
@@ -8,7 +8,8 @@ module Textus
8
8
  def call(store)
9
9
  key = positional.shift or raise UsageError.new("delete requires a key")
10
10
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
11
- emit(store.delete(key, if_etag: if_etag, as: role))
11
+ ctx = Textus::Composition.context(store, role: role)
12
+ emit(Textus::Composition.writes_delete(ctx).call(key, if_etag: if_etag))
12
13
  end
13
14
  end
14
15
  end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Freshness < Verb
5
+ option :prefix, "--prefix=KEY"
6
+ option :zone, "--zone=Z"
7
+
8
+ def call(store)
9
+ role = Role.resolve(flag: nil, env: ENV, root: store.root)
10
+ ctx = Textus::Composition.context(store, role: role)
11
+ rows = Textus::Composition.freshness(ctx).call(prefix: prefix, zone: zone)
12
+ emit({ "verb" => "freshness", "rows" => rows })
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -2,9 +2,16 @@ module Textus
2
2
  class CLI
3
3
  class Verb
4
4
  class Get < Verb
5
+ option :as_flag, "--as=ROLE"
6
+
5
7
  def call(store)
6
8
  key = positional.shift or raise UsageError.new("get requires a key")
7
- emit(store.get(key))
9
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
+ ctx = Textus::Composition.context(store, role: role)
11
+ result = Textus::Composition.reads_get(ctx).call(key)
12
+ raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
13
+
14
+ emit(result)
8
15
  end
9
16
  end
10
17
  end
@@ -23,16 +23,16 @@ module Textus
23
23
  end
24
24
 
25
25
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
26
- callable = store.registry.rpc_callable(:fetch, name)
26
+ callable = store.registry.rpc_callable(:intake, name)
27
27
  view = Store::View.new(store, writable: true, as: role)
28
28
 
29
29
  begin
30
- Timeout.timeout(Textus::Refresh::FETCH_TIMEOUT_SECONDS) do
30
+ Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
31
31
  callable.call(config: {}, store: view, args: args)
32
32
  end
33
33
  rescue Timeout::Error
34
34
  raise UsageError.new(
35
- "hook run '#{name}' exceeded #{Textus::Refresh::FETCH_TIMEOUT_SECONDS}s timeout",
35
+ "hook run '#{name}' exceeded #{Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS}s timeout",
36
36
  )
37
37
  rescue Textus::Error
38
38
  raise
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class PolicyExplain < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("policy explain requires a KEY")
7
+ role = Role.resolve(flag: nil, env: ENV, root: store.root)
8
+ ctx = Textus::Composition.context(store, role: role)
9
+ result = Textus::Composition.policy_explain(ctx).call(key: key)
10
+ emit({ "verb" => "policy_explain" }.merge(result.transform_keys(&:to_s)))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class PolicyList < Verb
5
+ def call(store)
6
+ policies = store.manifest.policies.blocks.map do |b|
7
+ row = { "match" => b.match }
8
+ if b.refresh
9
+ row["refresh"] = {
10
+ "ttl_seconds" => b.refresh.ttl_seconds,
11
+ "on_stale" => b.refresh.on_stale,
12
+ "sync_budget_ms" => b.refresh.sync_budget_ms,
13
+ }
14
+ end
15
+ row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
16
+ row["promote_requires"] = b.promote.requires if b.promote
17
+ row["retention"] = b.retention if b.retention
18
+ row
19
+ end
20
+ emit({ "verb" => "policy_list", "policies" => policies })
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -15,15 +15,15 @@ module Textus
15
15
  raw = @stdin.read
16
16
  payload =
17
17
  if fetch_name
18
- callable = store.registry.rpc_callable(:fetch, fetch_name)
18
+ callable = store.registry.rpc_callable(:intake, fetch_name)
19
19
  result =
20
20
  begin
21
- Timeout.timeout(Textus::Refresh::FETCH_TIMEOUT_SECONDS) do
21
+ Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
22
22
  callable.call(config: { "bytes" => raw }, store: Textus::Store::View.new(store), args: {})
23
23
  end
24
24
  rescue Timeout::Error
25
25
  raise UsageError.new(
26
- "fetch '#{fetch_name}' exceeded #{Textus::Refresh::FETCH_TIMEOUT_SECONDS}s timeout",
26
+ "fetch '#{fetch_name}' exceeded #{Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS}s timeout",
27
27
  )
28
28
  end
29
29
  basename = key.split(".").last
@@ -42,7 +42,8 @@ module Textus
42
42
  meta = payload["_meta"] || payload["frontmatter"] || {}
43
43
  body = payload["body"] || ""
44
44
  if_etag = payload["if_etag"]
45
- emit(store.put(key, meta: meta, body: body, if_etag: if_etag, as: role))
45
+ ctx = Textus::Composition.context(store, role: role)
46
+ emit(Textus::Composition.writes_put(ctx).call(key, meta: meta, body: body, if_etag: if_etag))
46
47
  end
47
48
  end
48
49
  end
@@ -7,7 +7,8 @@ module Textus
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("refresh requires a key")
9
9
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- emit(Textus::Refresh.call(store, key, as: role))
10
+ ctx = Textus::Composition.context(store, role: role)
11
+ emit(Textus::Composition.refresh_worker(ctx).run(key))
11
12
  end
12
13
  end
13
14
  end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class RefreshStale < Verb
5
+ option :prefix, "--prefix=KEY"
6
+ option :zone, "--zone=Z"
7
+ option :as_flag, "--as=ROLE"
8
+
9
+ def call(store)
10
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
11
+ ctx = Textus::Composition.context(store, role: role)
12
+ result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
13
+ emit(result)
14
+ exit(1) unless result["ok"]
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Reject < Verb
5
+ option :as_flag, "--as=ROLE"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("reject requires a key")
9
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
+ emit(store.reject(key, as: role))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
data/lib/textus/cli.rb CHANGED
@@ -7,22 +7,27 @@ module Textus
7
7
  # plus a new file under lib/textus/cli/.
8
8
  VERBS = {
9
9
  "accept" => Verb::Accept,
10
+ "audit" => Verb::Audit,
11
+ "blame" => Verb::Blame,
12
+ "reject" => Verb::Reject,
10
13
  "build" => Verb::Build,
11
14
  "delete" => Verb::Delete,
12
15
  "deps" => Verb::Deps,
13
16
  "doctor" => Verb::Doctor,
17
+ "freshness" => Verb::Freshness,
14
18
  "get" => Verb::Get,
15
19
  "hook" => Group::Hook,
16
20
  "init" => Verb::Init,
17
21
  "intro" => Verb::Intro,
18
22
  "key" => Group::Key,
19
23
  "list" => Verb::List,
24
+ "policy" => Group::Policy,
20
25
  "published" => Verb::Published,
21
26
  "put" => Verb::Put,
22
27
  "rdeps" => Verb::Rdeps,
23
28
  "refresh" => Verb::Refresh,
29
+ "refresh-stale" => Verb::RefreshStale,
24
30
  "schema" => Group::Schema,
25
- "stale" => Verb::Stale,
26
31
  "where" => Verb::Where,
27
32
  }.freeze
28
33
 
@@ -48,6 +53,10 @@ module Textus
48
53
  0
49
54
  when "--help", "-h" then print_help
50
55
  0
56
+ when "stale"
57
+ raise UsageError.new(
58
+ "textus stale was removed in 0.9.2 — use `textus freshness` instead",
59
+ )
51
60
  else
52
61
  klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
53
62
  dispatch(klass, argv)
@@ -84,13 +93,18 @@ module Textus
84
93
  textus where KEY
85
94
  textus get KEY
86
95
  textus put KEY --stdin [--fetch=NAME] --as=ROLE
87
- textus stale [--prefix=KEY] [--zone=Z]
96
+ textus freshness [--prefix=KEY] [--zone=Z]
97
+ textus refresh-stale [--prefix=KEY] [--zone=Z]
98
+ textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
99
+ textus blame KEY [--limit=N]
88
100
  textus doctor
89
101
  textus intro
90
102
 
91
103
  textus key {mv,uid,migrate}
92
104
  textus schema {show,init,diff,migrate}
93
105
  textus hook {list,run}
106
+ textus policy {list,explain}
107
+ textus migrate {zones,policies}
94
108
  HELP
95
109
  end
96
110
  end
@@ -0,0 +1,71 @@
1
+ module Textus
2
+ module Composition
3
+ module_function
4
+
5
+ def context(store, role:, correlation_id: nil, dry_run: false)
6
+ Textus::Application::Context.new(
7
+ store: store,
8
+ role: role,
9
+ correlation_id: correlation_id,
10
+ dry_run: dry_run,
11
+ )
12
+ end
13
+
14
+ def reads_get(ctx)
15
+ Textus::Application::Reads::Get.new(ctx: ctx, orchestrator: refresh_orchestrator(ctx))
16
+ end
17
+
18
+ def freshness(ctx)
19
+ Textus::Application::Reads::Freshness.new(ctx: ctx)
20
+ end
21
+
22
+ def audit(ctx)
23
+ Textus::Application::Reads::Audit.new(ctx: ctx)
24
+ end
25
+
26
+ def blame(ctx)
27
+ Textus::Application::Reads::Blame.new(ctx: ctx)
28
+ end
29
+
30
+ def policy_explain(ctx)
31
+ Textus::Application::Reads::PolicyExplain.new(ctx: ctx)
32
+ end
33
+
34
+ def refresh_worker(ctx)
35
+ Textus::Application::Refresh::Worker.new(ctx: ctx, bus: ctx.store.bus)
36
+ end
37
+
38
+ def refresh_orchestrator(ctx)
39
+ Textus::Application::Refresh::Orchestrator.new(
40
+ worker: refresh_worker(ctx),
41
+ bus: ctx.store.bus,
42
+ store_root: ctx.store.root,
43
+ store: ctx.store,
44
+ )
45
+ end
46
+
47
+ def writes_put(ctx)
48
+ Textus::Application::Writes::Put.new(ctx: ctx, bus: ctx.store.bus)
49
+ end
50
+
51
+ def writes_delete(ctx)
52
+ Textus::Application::Writes::Delete.new(ctx: ctx, bus: ctx.store.bus)
53
+ end
54
+
55
+ def writes_build(ctx)
56
+ Textus::Application::Writes::Build.new(ctx: ctx, bus: ctx.store.bus)
57
+ end
58
+
59
+ def writes_accept(ctx)
60
+ Textus::Application::Writes::Accept.new(ctx: ctx, bus: ctx.store.bus)
61
+ end
62
+
63
+ def writes_publish(ctx)
64
+ Textus::Application::Writes::Publish.new(ctx: ctx, bus: ctx.store.bus)
65
+ end
66
+
67
+ def event_bus(ctx)
68
+ Textus::Infra::EventBus.new(registry: ctx.store.registry)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,33 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # For every entry with an `intake.handler`, look up its handler_allowlist
5
+ # policy (if any) and verify the declared handler is allowed. Emits a
6
+ # failure when the handler is rejected by policy.
7
+ class HandlerAllowlist < Check
8
+ def call
9
+ out = []
10
+ store.manifest.entries.each do |mentry|
11
+ handler = mentry.intake_handler
12
+ next if handler.nil?
13
+
14
+ allow = store.manifest.policies_for(mentry.key).handler_allowlist
15
+ next if allow.nil?
16
+ next if allow.allows?(handler)
17
+
18
+ out << {
19
+ "code" => "policy.handler_not_allowed",
20
+ "level" => "error",
21
+ "subject" => mentry.key,
22
+ "message" => "entry '#{mentry.key}' declares intake.handler='#{handler}' but the " \
23
+ "handler_allowlist policy permits only: #{allow.handlers.join(", ")}",
24
+ "fix" => "either change intake.handler to one of [#{allow.handlers.join(", ")}], " \
25
+ "or extend the handler_allowlist policy in .textus/manifest.yaml",
26
+ }
27
+ end
28
+ out
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class IntakeRegistration < Check
5
+ BUILTIN = %i[json csv markdown-links ical-events rss].freeze
6
+
7
+ def call
8
+ declared = collect_declared_handlers
9
+ registered = store.registry.rpc_names(:intake).to_set
10
+
11
+ out = (declared - registered).map do |name|
12
+ {
13
+ "code" => "intake.handler_missing",
14
+ "level" => "error",
15
+ "subject" => name.to_s,
16
+ "message" => "manifest references intake handler '#{name}' but no Textus.intake(:#{name}) is registered",
17
+ "fix" => "create .textus/hooks/#{name}.rb with `Textus.intake(:#{name}) { ... }`",
18
+ }
19
+ end
20
+
21
+ (registered - declared - BUILTIN.to_set).each do |name|
22
+ out << {
23
+ "code" => "intake.handler_orphan",
24
+ "level" => "warning",
25
+ "subject" => name.to_s,
26
+ "message" => "Textus.intake(:#{name}) is registered but no manifest entry references it",
27
+ "fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
28
+ }
29
+ end
30
+
31
+ out
32
+ end
33
+
34
+ private
35
+
36
+ def collect_declared_handlers
37
+ set = Set.new
38
+ store.manifest.entries.each do |mentry|
39
+ set << mentry.intake_handler.to_sym if mentry.intake_handler
40
+ end
41
+ set
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end