textus 0.20.0 → 0.22.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -0
  3. data/README.md +7 -4
  4. data/SPEC.md +77 -5
  5. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  6. data/lib/textus/application/policy/promotion.rb +6 -11
  7. data/lib/textus/application/reads/audit.rb +40 -15
  8. data/lib/textus/application/reads/pulse.rb +63 -0
  9. data/lib/textus/application/reads/validator.rb +3 -1
  10. data/lib/textus/application/writes/accept.rb +5 -1
  11. data/lib/textus/application/writes/authority_gate.rb +26 -0
  12. data/lib/textus/application/writes/materializer.rb +1 -1
  13. data/lib/textus/application/writes/publish.rb +25 -106
  14. data/lib/textus/application/writes/reject.rb +5 -1
  15. data/lib/textus/{intro.rb → boot.rb} +71 -25
  16. data/lib/textus/builder/pipeline.rb +2 -2
  17. data/lib/textus/cli/verb/audit.rb +2 -0
  18. data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
  19. data/lib/textus/cli/verb/build.rb +2 -1
  20. data/lib/textus/cli/verb/pulse.rb +17 -0
  21. data/lib/textus/cli.rb +1 -1
  22. data/lib/textus/doctor/check/illegal_keys.rb +2 -3
  23. data/lib/textus/domain/policy/promote.rb +4 -2
  24. data/lib/textus/domain/policy/refresh.rb +2 -0
  25. data/lib/textus/errors.rb +16 -0
  26. data/lib/textus/infra/audit_log.rb +126 -16
  27. data/lib/textus/manifest/entry/base.rb +43 -6
  28. data/lib/textus/manifest/entry/derived.rb +40 -4
  29. data/lib/textus/manifest/entry/intake.rb +15 -3
  30. data/lib/textus/manifest/entry/leaf.rb +6 -5
  31. data/lib/textus/manifest/entry/nested.rb +42 -3
  32. data/lib/textus/manifest/entry/parser.rb +9 -51
  33. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  34. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  35. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  36. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  37. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  38. data/lib/textus/manifest/entry/validators.rb +1 -1
  39. data/lib/textus/manifest/entry.rb +3 -0
  40. data/lib/textus/manifest/resolver.rb +8 -5
  41. data/lib/textus/manifest/role_kinds.rb +21 -0
  42. data/lib/textus/manifest/schema.rb +63 -5
  43. data/lib/textus/manifest.rb +31 -1
  44. data/lib/textus/operations.rb +8 -1
  45. data/lib/textus/schema/tools.rb +8 -1
  46. data/lib/textus/store.rb +5 -1
  47. data/lib/textus/version.rb +1 -1
  48. metadata +9 -10
  49. data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
  50. data/lib/textus/application/tools/migrate_keys.rb +0 -191
  51. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
  52. data/lib/textus/cli/verb/key_normalize.rb +0 -48
  53. data/lib/textus/domain/policy.rb +0 -7
  54. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  55. data/lib/textus/manifest/resolution.rb +0 -5
@@ -1,13 +1,14 @@
1
1
  module Textus
2
2
  module Application
3
3
  module Writes
4
- # Single-pass publish use case: materializes Derived entries (template +
5
- # projection + external runner) AND copies Leaf/Nested entries to their
6
- # publish targets. Replaces the former two-step Build + Publish split.
4
+ # Single-pass publish use case: dispatches polymorphically to each
5
+ # entry's `publish_via` method. Derived entries materialize their body
6
+ # via Materializer; Nested entries fan out via publish_each; Leaf and
7
+ # Intake entries copy their stored body to publish_to targets. The
8
+ # Publish layer owns wiring (context, accumulation) but not per-kind
9
+ # logic.
7
10
  #
8
11
  # Return shape: { "protocol", "built", "published_leaves" }
9
- # — wire-compatible with what the `textus build` CLI verb previously
10
- # assembled by merging Build + old Publish results.
11
12
  class Publish
12
13
  def initialize(ctx:, manifest:, file_store:, bus:, root:, store:, hook_context:) # rubocop:disable Metrics/ParameterLists
13
14
  @ctx = ctx
@@ -22,26 +23,17 @@ module Textus
22
23
  def call(prefix: nil)
23
24
  built = []
24
25
  leaves = []
25
- repo_root = File.dirname(@root)
26
+ context = build_context
26
27
 
27
28
  @manifest.entries.each do |mentry|
28
29
  next if prefix && !entry_matches_prefix?(mentry, prefix)
29
30
 
30
- case mentry
31
- when Textus::Manifest::Entry::Derived
32
- next unless mentry.in_generator_zone?
31
+ result = mentry.publish_via(context, prefix: prefix)
32
+ next if result.nil?
33
33
 
34
- result = materialize_derived(mentry, repo_root)
35
- built << result if result
36
- when Textus::Manifest::Entry::Nested
37
- next unless mentry.publish_each
38
-
39
- publish_nested(mentry, repo_root, prefix, leaves)
40
- when Textus::Manifest::Entry::Leaf
41
- next if Array(mentry.publish_to).empty?
42
-
43
- result = publish_leaf_entry(mentry, repo_root)
44
- built << result if result
34
+ case result[:kind]
35
+ when :built then built << result[:value]
36
+ when :leaves then leaves.concat(result[:value])
45
37
  end
46
38
  end
47
39
 
@@ -50,86 +42,19 @@ module Textus
50
42
 
51
43
  private
52
44
 
53
- # Materialize a Derived entry and copy to publish_to targets.
54
- def materialize_derived(mentry, repo_root)
55
- target_path = Materializer.new(
56
- ctx: @ctx, manifest: @manifest, file_store: @file_store,
57
- bus: @bus, root: @root, store: @store
58
- ).run(mentry)
59
-
60
- publish_derived_copies(mentry, target_path, repo_root)
61
- fire_build_completed(mentry, target_path)
62
-
63
- { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
64
- end
65
-
66
- def publish_derived_copies(mentry, target_path, repo_root)
67
- envelope = reader.call(mentry.key)
68
- mentry.publish_to.each do |rel|
69
- target_abs = File.join(repo_root, rel)
70
- Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
71
- publish_event(:file_published,
72
- key: mentry.key,
73
- envelope: envelope,
74
- source: target_path,
75
- target: target_abs)
76
- end
77
- end
78
-
79
- def fire_build_completed(mentry, target_path) # rubocop:disable Lint/UnusedMethodArgument
80
- envelope = reader.call(mentry.key)
81
- src = mentry.source
82
- selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
83
- publish_event(:build_completed,
84
- key: mentry.key,
85
- envelope: envelope,
86
- sources: selects)
87
- end
88
-
89
- # Publish each leaf under a Nested entry's publish_each pattern.
90
- def publish_nested(mentry, repo_root, prefix, accumulator)
91
- @manifest.resolver.enumerate(prefix: mentry.key).each do |row|
92
- next unless row[:manifest_entry].equal?(mentry)
93
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
94
-
95
- accumulator << publish_nested_leaf(mentry, row, repo_root)
96
- end
97
- end
98
-
99
- def publish_nested_leaf(mentry, row, repo_root)
100
- target_rel = mentry.publish_target_for(row[:key])
101
- target_abs = File.expand_path(File.join(repo_root, target_rel))
102
- unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
103
- raise PublishError.new(
104
- "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
105
- )
106
- end
107
-
108
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
109
- publish_event(:file_published,
110
- key: row[:key],
111
- envelope: reader.call(row[:key]),
112
- source: row[:path],
113
- target: target_abs)
114
- { "key" => row[:key], "source" => row[:path], "target" => target_abs }
115
- end
116
-
117
- # Publish a standalone Leaf entry that has publish_to targets.
118
- def publish_leaf_entry(mentry, repo_root)
119
- source_path = @manifest.resolver.resolve(mentry.key).path
120
- envelope = reader.call(mentry.key)
121
-
122
- mentry.publish_to.each do |rel|
123
- target_abs = File.join(repo_root, rel)
124
- Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: @root)
125
- publish_event(:file_published,
126
- key: mentry.key,
127
- envelope: envelope,
128
- source: source_path,
129
- target: target_abs)
130
- end
131
-
132
- { "key" => mentry.key, "path" => source_path, "published_to" => mentry.publish_to }
45
+ def build_context
46
+ Textus::Manifest::Entry::Base::PublishContext.new(
47
+ repo_root: File.dirname(@root),
48
+ manifest: @manifest,
49
+ file_store: @file_store,
50
+ root: @root,
51
+ store: @store,
52
+ ctx: @ctx,
53
+ bus: @bus,
54
+ hook_context: @hook_context,
55
+ reader: reader,
56
+ emit: ->(event, **payload) { @bus.publish(event, ctx: @hook_context, **payload) },
57
+ )
133
58
  end
134
59
 
135
60
  # Whether the entry should be processed for the given prefix filter.
@@ -138,8 +63,6 @@ module Textus
138
63
 
139
64
  case mentry
140
65
  when Textus::Manifest::Entry::Nested
141
- # Nested: process if the entry key is a prefix of `prefix` or
142
- # `prefix` is a prefix of the entry key (a leaf under it).
143
66
  mentry.key.start_with?(prefix) ||
144
67
  prefix.start_with?("#{mentry.key}.")
145
68
  else
@@ -152,10 +75,6 @@ module Textus
152
75
  ctx: @ctx, manifest: @manifest, file_store: @file_store,
153
76
  )
154
77
  end
155
-
156
- def publish_event(event, **payload)
157
- @bus.publish(event, ctx: @hook_context, **payload)
158
- end
159
78
  end
160
79
  end
161
80
  end
@@ -1,7 +1,11 @@
1
+ require_relative "authority_gate"
2
+
1
3
  module Textus
2
4
  module Application
3
5
  module Writes
4
6
  class Reject
7
+ include AuthorityGate
8
+
5
9
  def initialize(ctx:, manifest:, file_store:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
10
  @ctx = ctx
7
11
  @manifest = manifest
@@ -13,7 +17,7 @@ module Textus
13
17
  end
14
18
 
15
19
  def call(pending_key)
16
- raise ProposalError.new("only human role can reject proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
20
+ assert_accept_authority!("reject")
17
21
 
18
22
  mentry = @manifest.resolver.resolve(pending_key).entry
19
23
  unless mentry.in_proposal_zone?
@@ -4,8 +4,8 @@ module Textus
4
4
  # project: zones and their write authority, entries and their flags,
5
5
  # registered hooks, write flows, and the CLI verb catalog.
6
6
  #
7
- # Intro is side-effect-free.
8
- module Intro
7
+ # Boot is side-effect-free.
8
+ module Boot
9
9
  PROTOCOL_ID = PROTOCOL
10
10
 
11
11
  # Conventional zone purposes. Unknown zones (declared in the manifest
@@ -18,19 +18,37 @@ module Textus
18
18
  "output" => "build-computed outputs; never hand-edited",
19
19
  }.freeze
20
20
 
21
- WRITE_FLOWS = {
22
- "human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
23
- "agent" => "propose changes by writing 'review.*' entries with --as=agent and a 'proposal:' frontmatter block; " \
24
- "a human runs 'textus accept' to apply",
25
- "runner" => "refresh intake entries with 'textus refresh KEY --as=runner' (uses the entry's declared action)",
26
- "builder" => "'textus build' computes output entries from projections; output files are never hand-edited",
21
+ # Per-kind write-flow templates. Each lambda receives the user-facing role
22
+ # name and returns a guidance string for that role. Roles whose kind has
23
+ # no template (e.g. unknown future kinds) are omitted from write_flows.
24
+ WRITE_FLOW_TEMPLATES = {
25
+ accept_authority: lambda do |name, _manifest|
26
+ "edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
27
+ end,
28
+ proposer: lambda do |name, manifest|
29
+ authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
30
+ "propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
31
+ "the #{authority} role runs 'textus accept' to apply"
32
+ end,
33
+ runner: lambda do |name, _manifest|
34
+ "refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
35
+ end,
36
+ generator: lambda do |_name, _manifest|
37
+ "'textus build' computes output entries from projections; output files are never hand-edited"
38
+ end,
27
39
  }.freeze
28
40
 
29
- # Static, store-independent guide to the agent-facing protocol. Surfaced
30
- # under the new top-level `agent_protocol` key in Intro.run. Recipes
31
- # describe CLI verbs (not Ruby Operations) because the audience is an
32
- # agent driving textus from the command line.
33
- AGENT_PROTOCOL = {
41
+ def self.write_flows_for(manifest)
42
+ manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
43
+ tmpl = WRITE_FLOW_TEMPLATES[kind]
44
+ acc[name] = tmpl.call(name, manifest) if tmpl
45
+ end
46
+ end
47
+
48
+ # Static, store-independent parts of the agent-facing protocol. The
49
+ # `role_resolution` block is derived per-manifest in agent_protocol(...)
50
+ # because role names are user-configurable.
51
+ AGENT_PROTOCOL_TEMPLATE = {
34
52
  "envelope_shape" => {
35
53
  "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
36
54
  "fields" => {
@@ -41,11 +59,6 @@ module Textus
41
59
  },
42
60
  "ref" => "SPEC.md §8",
43
61
  },
44
- "role_resolution" => {
45
- "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
46
- "roles" => %w[human agent runner builder],
47
- "ref" => "SPEC.md §5",
48
- },
49
62
  "recipes" => {
50
63
  "read" => {
51
64
  "purpose" => "find and read an entry",
@@ -82,17 +95,17 @@ module Textus
82
95
  }.freeze
83
96
 
84
97
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
85
- # Agents that read intro should see a stable shape regardless of how
98
+ # Agents that read boot should see a stable shape regardless of how
86
99
  # verb implementations evolve.
87
100
  CLI_VERBS = [
88
- { "name" => "intro", "summary" => "this output — orientation for agents and tools" },
101
+ { "name" => "boot", "summary" => "this output — orientation for agents and tools" },
89
102
  { "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
90
103
  { "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
91
104
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
92
105
  { "name" => "schema", "summary" => "field shape for a key family" },
93
106
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
94
107
  { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
95
- { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key normalize'" },
108
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
96
109
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
97
110
  { "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
98
111
  { "name" => "refresh", "summary" => "run an action for an intake entry" },
@@ -103,8 +116,40 @@ module Textus
103
116
  { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
104
117
  { "name" => "hook",
105
118
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
119
+ { "name" => "pulse",
120
+ "summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
106
121
  ].freeze
107
122
 
123
+ def self.agent_quickstart(manifest, store)
124
+ proposer_roles = manifest.roles_with_kind(:proposer)
125
+ agent_role = proposer_roles.first
126
+
127
+ writable_zones = manifest.zones.each_with_object([]) do |(zname, writers), acc|
128
+ acc << zname if agent_role && writers.include?(agent_role)
129
+ end
130
+
131
+ propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
132
+
133
+ {
134
+ "read_verbs" => %w[boot get list audit pulse freshness doctor],
135
+ "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
136
+ "writable_zones" => writable_zones,
137
+ "propose_zone" => propose_zone,
138
+ "latest_seq" => store.audit_log.latest_seq,
139
+ }
140
+ end
141
+
142
+ def self.agent_protocol(manifest)
143
+ AGENT_PROTOCOL_TEMPLATE.merge(
144
+ "role_resolution" => {
145
+ "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
146
+ "default 'human'",
147
+ "roles" => manifest.role_mapping.keys,
148
+ "ref" => "SPEC.md §5",
149
+ },
150
+ )
151
+ end
152
+
108
153
  def self.run(store)
109
154
  {
110
155
  "protocol" => PROTOCOL_ID,
@@ -112,9 +157,10 @@ module Textus
112
157
  "zones" => zones_for(store),
113
158
  "entries" => entries_for(store),
114
159
  "hooks" => hooks_for(store),
115
- "write_flows" => WRITE_FLOWS.dup,
160
+ "write_flows" => write_flows_for(store.manifest),
116
161
  "cli_verbs" => CLI_VERBS.map(&:dup),
117
- "agent_protocol" => AGENT_PROTOCOL,
162
+ "agent_protocol" => agent_protocol(store.manifest),
163
+ "agent_quickstart" => agent_quickstart(store.manifest, store),
118
164
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
119
165
  }
120
166
  end
@@ -130,7 +176,7 @@ module Textus
130
176
 
131
177
  def self.entries_for(store)
132
178
  store.manifest.entries.map do |e|
133
- derived = store.manifest.zone_writers(e.zone).include?("builder")
179
+ derived = store.manifest.zone_kinds(e.zone).include?(:generator)
134
180
  {
135
181
  "key" => e.key,
136
182
  "zone" => e.zone,
@@ -141,7 +187,7 @@ module Textus
141
187
  "derived" => derived,
142
188
  "intake" => e.is_a?(Textus::Manifest::Entry::Intake),
143
189
  "publish_to" => Array(e.publish_to),
144
- "publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
190
+ "publish_each" => e.publish_each,
145
191
  }
146
192
  end
147
193
  end
@@ -64,7 +64,7 @@ module Textus
64
64
 
65
65
  # rubocop:disable Metrics/ParameterLists
66
66
  def self.run(mentry:, manifest:, reader:, lister:, transform_resolver:, template_loader:,
67
- transform_context: nil, inject_intro: nil)
67
+ transform_context: nil, inject_boot: nil)
68
68
  # 1. Load sources + project + reduce
69
69
  data =
70
70
  if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
@@ -78,7 +78,7 @@ module Textus
78
78
  else
79
79
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
80
80
  end
81
- data = data.merge("intro" => inject_intro.call) if mentry.inject_intro && inject_intro
81
+ data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
82
82
 
83
83
  # 2. Render
84
84
  klass = renderers[mentry.format] or
@@ -9,6 +9,7 @@ module Textus
9
9
  option :role_filter, "--role=ROLE"
10
10
  option :verb_filter, "--verb=V"
11
11
  option :since, "--since=ISO8601|RELATIVE"
12
+ option :seq_since, "--seq-since=N"
12
13
  option :correlation_id, "--correlation-id=ID"
13
14
  option :limit, "--limit=N"
14
15
 
@@ -21,6 +22,7 @@ module Textus
21
22
  role: role_filter,
22
23
  verb: verb_filter,
23
24
  since: since_time,
25
+ seq_since: seq_since&.to_i,
24
26
  correlation_id: correlation_id,
25
27
  limit: limit&.to_i,
26
28
  )
@@ -1,11 +1,11 @@
1
1
  module Textus
2
2
  class CLI
3
3
  class Verb
4
- class Intro < Verb
5
- command_name "intro"
4
+ class Boot < Verb
5
+ command_name "boot"
6
6
 
7
7
  def call(store)
8
- emit(Textus::Intro.run(store))
8
+ emit(Textus::Boot.run(store))
9
9
  end
10
10
  end
11
11
  end
@@ -8,7 +8,8 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  Textus::Infra::BuildLock.with(root: store.root) do
11
- ops = Textus::Operations.for(store, role: "builder")
11
+ role = store.manifest.roles_with_kind(:generator).first || "builder"
12
+ ops = Textus::Operations.for(store, role: role)
12
13
  result = ops.publish(prefix: prefix)
13
14
  emit(result)
14
15
  end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Pulse < Verb
5
+ command_name "pulse"
6
+
7
+ option :since, "--since=N"
8
+
9
+ def call(store)
10
+ ops = operations_for(store)
11
+ since_n = (since || "0").to_i
12
+ emit(ops.pulse(since: since_n))
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
data/lib/textus/cli.rb CHANGED
@@ -99,7 +99,7 @@ module Textus
99
99
  textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
100
100
  textus blame KEY [--limit=N]
101
101
  textus doctor
102
- textus intro
102
+ textus boot
103
103
 
104
104
  textus key {mv,uid,normalize}
105
105
  textus rule {list,explain}
@@ -44,15 +44,14 @@ module Textus
44
44
  end
45
45
 
46
46
  def issue(abs_path, stem)
47
- proposed = Textus::Application::Tools::MigrateKeys.normalize(stem)
48
47
  {
49
48
  "code" => "key.illegal",
50
49
  "level" => "error",
51
50
  "subject" => abs_path,
52
51
  "path" => abs_path,
53
- "proposed_key" => proposed,
54
52
  "message" => "illegal key segment '#{stem}' at #{abs_path}",
55
- "fix" => "run 'textus key normalize --dry-run' then '--write' to rename to '#{proposed}'",
53
+ "fix" => "rename the file/directory so each segment matches [a-z0-9][a-z0-9-]* " \
54
+ "(lowercase, digits, hyphens)",
56
55
  }
57
56
  end
58
57
 
@@ -2,14 +2,16 @@ module Textus
2
2
  module Domain
3
3
  module Policy
4
4
  class Promote
5
- KNOWN = %i[schema_valid human_accept].freeze
5
+ KNOWN = %i[schema_valid accept_authority_signed].freeze
6
6
  attr_reader :requires
7
7
 
8
8
  def initialize(requires:)
9
9
  syms = Array(requires).map { |r| r.to_s.to_sym }
10
10
  unknown = syms - KNOWN
11
11
  unless unknown.empty?
12
- raise Textus::UsageError.new("unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})")
12
+ raise Textus::UsageError.new(
13
+ "unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})",
14
+ )
13
15
  end
14
16
 
15
17
  @requires = syms
@@ -2,6 +2,8 @@ module Textus
2
2
  module Domain
3
3
  module Policy
4
4
  class Refresh
5
+ ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
6
+
5
7
  attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
6
8
 
7
9
  def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
data/lib/textus/errors.rb CHANGED
@@ -215,4 +215,20 @@ module Textus
215
215
  )
216
216
  end
217
217
  end
218
+
219
+ class CursorExpired < Error
220
+ attr_reader :requested, :min_available
221
+
222
+ def initialize(requested:, min_available:)
223
+ @requested = requested
224
+ @min_available = min_available
225
+ super(
226
+ "cursor_expired",
227
+ "audit cursor expired: requested seq=#{requested} but oldest available is #{min_available}; " \
228
+ "call `textus boot` to re-orient and resume from latest_seq",
229
+ details: { "requested" => requested, "min_available" => min_available },
230
+ hint: "call `textus boot` to get the current latest_seq and resume from there",
231
+ )
232
+ end
233
+ end
218
234
  end