textus 0.29.0 → 0.35.1

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -235
  3. data/CHANGELOG.md +169 -0
  4. data/README.md +85 -64
  5. data/SPEC.md +366 -201
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +93 -76
  8. data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
  9. data/lib/textus/cli/verb/build.rb +1 -1
  10. data/lib/textus/cli/verb/fetch.rb +14 -0
  11. data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
  12. data/lib/textus/cli/verb/get.rb +1 -1
  13. data/lib/textus/cli/verb/hook_run.rb +2 -6
  14. data/lib/textus/cli/verb/hooks.rb +1 -1
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +8 -8
  18. data/lib/textus/cli.rb +21 -18
  19. data/lib/textus/container.rb +1 -2
  20. data/lib/textus/dispatcher.rb +11 -3
  21. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
  22. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  23. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  24. data/lib/textus/doctor/check.rb +8 -5
  25. data/lib/textus/doctor.rb +2 -1
  26. data/lib/textus/domain/action.rb +3 -3
  27. data/lib/textus/domain/duration.rb +22 -0
  28. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  29. data/lib/textus/domain/freshness/policy.rb +2 -2
  30. data/lib/textus/domain/freshness.rb +7 -7
  31. data/lib/textus/domain/outcome.rb +2 -2
  32. data/lib/textus/domain/permission.rb +2 -10
  33. data/lib/textus/domain/policy/base_guards.rb +25 -0
  34. data/lib/textus/domain/policy/evaluation.rb +18 -0
  35. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
  36. data/lib/textus/domain/policy/guard.rb +35 -0
  37. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  38. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  39. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  40. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  41. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  42. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  43. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  44. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  45. data/lib/textus/domain/policy/retention.rb +26 -0
  46. data/lib/textus/domain/retention.rb +44 -0
  47. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  48. data/lib/textus/envelope/io/reader.rb +4 -0
  49. data/lib/textus/envelope/io/writer.rb +8 -0
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +12 -24
  53. data/lib/textus/hooks/rpc_registry.rb +9 -35
  54. data/lib/textus/hooks/signature.rb +31 -0
  55. data/lib/textus/init.rb +24 -18
  56. data/lib/textus/maintenance/zone_mv.rb +1 -1
  57. data/lib/textus/manifest/capabilities.rb +29 -0
  58. data/lib/textus/manifest/data.rb +16 -8
  59. data/lib/textus/manifest/entry/base.rb +2 -2
  60. data/lib/textus/manifest/policy.rb +62 -19
  61. data/lib/textus/manifest/rules.rb +25 -14
  62. data/lib/textus/manifest/schema.rb +78 -38
  63. data/lib/textus/manifest.rb +6 -5
  64. data/lib/textus/mcp/server.rb +2 -10
  65. data/lib/textus/mcp/session.rb +7 -23
  66. data/lib/textus/mcp/tool_schemas.rb +3 -3
  67. data/lib/textus/mcp/tools.rb +7 -7
  68. data/lib/textus/ports/audit_subscriber.rb +1 -1
  69. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  70. data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
  71. data/lib/textus/projection.rb +1 -1
  72. data/lib/textus/read/freshness.rb +9 -9
  73. data/lib/textus/read/get.rb +8 -8
  74. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  75. data/lib/textus/read/policy_explain.rb +19 -10
  76. data/lib/textus/read/pulse.rb +5 -4
  77. data/lib/textus/read/retainable.rb +17 -0
  78. data/lib/textus/read/validator.rb +1 -1
  79. data/lib/textus/role_scope.rb +3 -2
  80. data/lib/textus/schema/tools.rb +5 -5
  81. data/lib/textus/version.rb +1 -1
  82. data/lib/textus/write/accept.rb +19 -55
  83. data/lib/textus/write/delete.rb +15 -17
  84. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
  85. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  86. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
  87. data/lib/textus/write/intake_fetch.rb +23 -0
  88. data/lib/textus/write/mv.rb +17 -15
  89. data/lib/textus/write/put.rb +15 -17
  90. data/lib/textus/write/reject.rb +11 -5
  91. data/lib/textus/write/retention_sweep.rb +55 -0
  92. metadata +32 -18
  93. data/lib/textus/cli/verb/refresh.rb +0 -14
  94. data/lib/textus/domain/authorizer.rb +0 -37
  95. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  96. data/lib/textus/domain/policy/promote.rb +0 -26
  97. data/lib/textus/domain/policy/promotion.rb +0 -57
  98. data/lib/textus/manifest/role_kinds.rb +0 -21
  99. data/lib/textus/write/authority_gate.rb +0 -24
@@ -1,48 +1,59 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Textus
2
4
  module Domain
3
5
  module Policy
4
6
  module Predicates
7
+ # Predicate: the entry's effective frontmatter satisfies the schema
8
+ # bound to the target key. For accept, the frontmatter lives under
9
+ # envelope.meta["frontmatter"]; for a direct put it is envelope.meta.
5
10
  class SchemaValid
6
11
  attr_reader :reason
7
12
 
8
- def name
9
- "schema_valid"
13
+ def initialize(schemas:)
14
+ @schemas = schemas
10
15
  end
11
16
 
12
- def call(entry:, schemas:, manifest:) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
13
- return true if entry.nil? || manifest.nil? || schemas.nil?
17
+ def name = "schema_valid"
18
+
19
+ def call(eval)
20
+ manifest = eval.manifest
21
+ return true if eval.envelope.nil? || manifest.nil? || @schemas.nil?
14
22
 
15
- target_key = entry.meta&.dig("proposal", "target_key")
23
+ target_key = eval.target
16
24
  return true unless target_key
17
25
 
18
26
  mentry = manifest.resolver.resolve(target_key).entry
19
27
  schema_ref = mentry&.schema
20
28
  return true unless schema_ref
21
29
 
22
- schema = schemas.fetch_or_nil(schema_ref)
30
+ schema = @schemas.fetch_or_nil(schema_ref)
23
31
  return true unless schema
24
32
 
25
- frontmatter = entry.meta&.dig("frontmatter") || {}
33
+ frontmatter =
34
+ eval.envelope.meta&.dig("frontmatter") || eval.envelope.meta || {}
26
35
  begin
27
36
  schema.validate!(frontmatter)
37
+ true
28
38
  rescue Textus::SchemaViolation => e
29
- @reason = e.message.dup
30
- d = e.details
31
- if d.is_a?(Hash)
32
- if d["missing"]
33
- @reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
34
- elsif d["field"]
35
- @reason = "field '#{d["field"]}': #{d["reason"]}"
36
- end
37
- end
38
- return false
39
+ @reason = humanize(e)
40
+ false
39
41
  end
40
-
41
- true
42
42
  rescue StandardError => e
43
43
  @reason = "schema validation error: #{e.message}"
44
44
  false
45
45
  end
46
+
47
+ private
48
+
49
+ def humanize(err)
50
+ d = err.details
51
+ return err.message.dup unless d.is_a?(Hash)
52
+ return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
53
+ return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
54
+
55
+ err.message.dup
56
+ end
46
57
  end
47
58
  end
48
59
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # Predicate: a proposal may only target a `canon` zone (ADR 0035). Runs
8
+ # on the `accept` floor, where Evaluation#target is the proposal's
9
+ # resolved target_key. Refuses promotion into workspace/derived/
10
+ # quarantine/queue — the queue→canon path is the only coherent one.
11
+ # No bespoke #error; failures accumulate into GuardFailed (ADR 0031).
12
+ class TargetIsCanon
13
+ attr_reader :reason
14
+
15
+ def name = "target_is_canon"
16
+
17
+ def call(eval)
18
+ zone = eval.manifest.resolver.resolve(eval.target).entry.zone
19
+ kind = eval.manifest.policy.declared_kind(zone.to_s)
20
+ return true if kind == :canon
21
+
22
+ @reason = "proposal target '#{eval.target}' is in zone '#{zone}' " \
23
+ "(kind: #{kind || "none"}); proposals may only target a canon zone"
24
+ false
25
+ rescue Textus::UnknownKey
26
+ @reason = "proposal target '#{eval.target}' resolves to no declared entry"
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ module Policy
6
+ module Predicates
7
+ # Predicate #0 of every write guard. Wraps the post-0.31.0 capability
8
+ # topology gate (role.can ⊇ verb_for(zone.kind)). On failure, #error
9
+ # raises the capability-shaped WriteForbidden so the topology refusal
10
+ # — textus's signature product feature — is unchanged.
11
+ class ZoneWritableBy
12
+ attr_reader :reason
13
+
14
+ def name = "zone_writable_by"
15
+
16
+ def call(eval)
17
+ manifest = eval.manifest
18
+ @mentry = manifest.resolver.resolve(eval.target).entry
19
+ return true if manifest.policy.permission_for(@mentry.zone.to_s).allows_write?(eval.actor)
20
+
21
+ @verb = manifest.policy.verb_for_zone(@mentry.zone) # capability the kind requires
22
+ @holders = manifest.policy.roles_with_capability(@verb)
23
+ @reason = "zone '#{@mentry.zone}' needs capability '#{@verb}'; '#{eval.actor}' lacks it"
24
+ false
25
+ end
26
+
27
+ # Matches the capability-shaped WriteForbidden landed by ADR 0030
28
+ # Task 3:
29
+ # WriteForbidden.new(key, zone, verb:, holders:)
30
+ # → "writing '<k>' (zone '<z>') needs capability '<verb>'",
31
+ # hint: "held by: <holders>; pass --as=<role>".
32
+ def error(_eval)
33
+ Textus::WriteForbidden.new(@mentry.key, @mentry.zone, verb: @verb, holders: @holders)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ # Lifetime policy for queue/quarantine leaves. Both windows are optional
5
+ # durations (see Domain::Duration). `expire_after` deletes; `archive_after`
6
+ # moves the leaf aside. When both are set, expire wins once its (longer)
7
+ # window is exceeded.
8
+ class Retention
9
+ attr_reader :expire_after, :archive_after
10
+
11
+ def initialize(expire_after: nil, archive_after: nil)
12
+ @expire_after = Textus::Domain::Duration.seconds(expire_after)
13
+ @archive_after = Textus::Domain::Duration.seconds(archive_after)
14
+ end
15
+
16
+ # :expire | :archive | nil for a leaf of the given age (seconds).
17
+ def action_for(age_seconds)
18
+ return :expire if @expire_after && age_seconds > @expire_after
19
+ return :archive if @archive_after && age_seconds > @archive_after
20
+
21
+ nil
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Domain
3
+ # Reports leaves whose age (now - file mtime) exceeds a retention window.
4
+ # Each row is { "key", "path", "action" => "expire"|"archive", "age_seconds" }.
5
+ class Retention
6
+ def initialize(manifest:, file_stat:, clock:)
7
+ @manifest = manifest
8
+ @file_stat = file_stat
9
+ @clock = clock
10
+ end
11
+
12
+ def call(prefix: nil, zone: nil)
13
+ @manifest.data.entries
14
+ .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
15
+ .flat_map { |m| rows_for(m) }
16
+ end
17
+
18
+ private
19
+
20
+ def rows_for(mentry)
21
+ policy = @manifest.rules.for(mentry.key).retention
22
+ return [] if policy.nil?
23
+
24
+ @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
25
+ path = row[:path]
26
+ next unless @file_stat.exists?(path)
27
+
28
+ age = (@clock.now - @file_stat.mtime(path)).to_i
29
+ action = policy.action_for(age)
30
+ next if action.nil?
31
+
32
+ { "key" => row[:key], "path" => path, "action" => action.to_s, "age_seconds" => age }
33
+ end
34
+ end
35
+
36
+ def entry_matches?(mentry, prefix:, zone:)
37
+ return false if zone && mentry.zone != zone
38
+ return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
39
+
40
+ true
41
+ end
42
+ end
43
+ end
44
+ end
@@ -15,7 +15,7 @@ module Textus
15
15
  def rows_for(mentry)
16
16
  return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
17
17
 
18
- ttl = @manifest.rules.for(mentry.key).refresh&.ttl_seconds
18
+ ttl = @manifest.rules.for(mentry.key).fetch&.ttl_seconds
19
19
  return [] unless ttl
20
20
 
21
21
  path = Textus::Key::Path.resolve(@manifest.data, mentry)
@@ -26,17 +26,17 @@ module Textus
26
26
  private
27
27
 
28
28
  def ttl_reason(mentry, path, ttl)
29
- return "never refreshed" unless @file_stat.exists?(path)
29
+ return "never fetched" unless @file_stat.exists?(path)
30
30
 
31
- last_str = last_refreshed_of(mentry, path)
32
- return "never refreshed (no last_refreshed_at)" if last_str.nil?
31
+ last_str = last_fetched_of(mentry, path)
32
+ return "never fetched (no last_fetched_at)" if last_str.nil?
33
33
 
34
34
  last = parse_time(last_str)
35
35
  "ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
36
36
  end
37
37
 
38
- def last_refreshed_of(mentry, path)
39
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_refreshed_at"]
38
+ def last_fetched_of(mentry, path)
39
+ Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
40
40
  end
41
41
 
42
42
  def parse_time(str)
@@ -8,6 +8,10 @@ module Textus
8
8
  #
9
9
  # No audit, no events, no permission checks — those live one layer up.
10
10
  class Reader
11
+ def self.from(container:)
12
+ new(file_store: container.file_store, manifest: container.manifest)
13
+ end
14
+
11
15
  def initialize(file_store:, manifest:)
12
16
  @file_store = file_store
13
17
  @manifest = manifest
@@ -14,6 +14,14 @@ module Textus
14
14
  class Writer
15
15
  Payload = Data.define(:meta, :body, :content)
16
16
 
17
+ def self.from(container:, call:)
18
+ new(
19
+ file_store: container.file_store, manifest: container.manifest,
20
+ schemas: container.schemas, audit_log: container.audit_log,
21
+ call: call, reader: Reader.from(container: container)
22
+ )
23
+ end
24
+
17
25
  def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
18
26
  @file_store = file_store
19
27
  @manifest = manifest
@@ -55,10 +55,10 @@ module Textus
55
55
  freshness.stale == true
56
56
  end
57
57
 
58
- def refreshing?
58
+ def fetching?
59
59
  return false if freshness.nil?
60
60
 
61
- freshness.refreshing == true
61
+ freshness.fetching == true
62
62
  end
63
63
  end
64
64
  end
data/lib/textus/errors.rb CHANGED
@@ -90,39 +90,21 @@ module Textus
90
90
  end
91
91
 
92
92
  class WriteForbidden < Error
93
- def initialize(k, z, writers: nil)
94
- writers_str =
95
- if writers && !writers.empty?
96
- writers.join(", ")
93
+ def initialize(k, z, verb: nil, holders: nil)
94
+ holders_str =
95
+ if holders && !holders.empty?
96
+ holders.join(", ")
97
97
  else
98
- "the role(s) listed in the manifest 'write_policy:'"
98
+ "no declared role"
99
99
  end
100
100
  details = { "key" => k, "zone" => z }
101
- details["writers"] = writers if writers
101
+ details["verb"] = verb if verb
102
+ details["holders"] = holders if holders
102
103
  super(
103
104
  "write_forbidden",
104
- "zone '#{z}' is not agent-writable for key '#{k}'",
105
+ "writing '#{k}' (zone '#{z}') needs capability '#{verb}'",
105
106
  details: details,
106
- hint: "this zone is writable by #{writers_str}; pass --as=<role>",
107
- )
108
- end
109
- end
110
-
111
- class ReadForbidden < Error
112
- def initialize(k, z, readers: nil)
113
- readers_str =
114
- if readers && !readers.empty?
115
- readers.join(", ")
116
- else
117
- "the role(s) listed in the manifest 'read_policy:'"
118
- end
119
- details = { "key" => k, "zone" => z }
120
- details["readers"] = readers if readers
121
- super(
122
- "read_forbidden",
123
- "zone '#{z}' is not readable by role for key '#{k}'",
124
- details: details,
125
- hint: "this zone is readable by #{readers_str}; pass --as=<role>",
107
+ hint: "held by: #{holders_str}; pass --as=<role>",
126
108
  )
127
109
  end
128
110
  end
@@ -163,7 +145,7 @@ module Textus
163
145
  "invalid_role",
164
146
  message || "role '#{r}' is not declared in any zone",
165
147
  details: { "role" => r },
166
- hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under zones[].write_policy",
148
+ hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under roles: (each with a can: list)",
167
149
  )
168
150
  end
169
151
  end
@@ -204,6 +186,21 @@ module Textus
204
186
  def initialize(m) = super("proposal_error", m)
205
187
  end
206
188
 
189
+ class GuardFailed < Error
190
+ def initialize(failed)
191
+ # failed: [[predicate_name, reason], ...]
192
+ rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
193
+ names = failed.map(&:first)
194
+ super(
195
+ "guard_failed",
196
+ "guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
197
+ details: { "failed" => rows },
198
+ hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
199
+ "unmet: #{names.join(", ")}",
200
+ )
201
+ end
202
+ end
203
+
207
204
  class FlagRenamed < Error
208
205
  def initialize(old_flag, new_flag)
209
206
  super(
@@ -10,16 +10,16 @@ module Textus
10
10
  EVENTS = {
11
11
  entry_put: %i[ctx key envelope],
12
12
  entry_deleted: %i[ctx key],
13
- entry_refreshed: %i[ctx key envelope change],
13
+ entry_fetched: %i[ctx key envelope change],
14
14
  entry_renamed: %i[ctx key from_key to_key envelope],
15
15
  build_completed: %i[ctx key envelope sources],
16
16
  proposal_accepted: %i[ctx key target_key],
17
17
  proposal_rejected: %i[ctx key target_key],
18
18
  file_published: %i[ctx key envelope source target],
19
19
  store_loaded: %i[ctx],
20
- refresh_started: %i[ctx key mode],
21
- refresh_failed: %i[ctx key error_class error_message],
22
- refresh_backgrounded: %i[ctx key started_at budget_ms],
20
+ fetch_started: %i[ctx key mode],
21
+ fetch_failed: %i[ctx key error_class error_message],
22
+ fetch_backgrounded: %i[ctx key started_at budget_ms],
23
23
  }.freeze
24
24
 
25
25
  RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
@@ -39,7 +39,12 @@ module Textus
39
39
  raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
40
40
 
41
41
  required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
42
- shape_check!(event_sym, required, blk)
42
+ sig = Signature.new(blk)
43
+ missing = sig.missing(required)
44
+ if missing.any?
45
+ raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
46
+ end
47
+
43
48
  name = name.to_sym
44
49
  raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
45
50
 
@@ -79,8 +84,9 @@ module Textus
79
84
  private
80
85
 
81
86
  def invoke(event, sub, key, kwargs)
82
- accepted = filter_kwargs(sub[:callable], kwargs)
87
+ accepted = Signature.new(sub[:callable]).filter(kwargs)
83
88
  error = nil
89
+ # Thread#kill is unsafe in general but bounded here: post-commit, isolated, only a runaway user hook is affected.
84
90
  thread = Thread.new do
85
91
  sub[:callable].call(**accepted)
86
92
  rescue StandardError => e
@@ -115,24 +121,6 @@ module Textus
115
121
  end
116
122
  end
117
123
 
118
- def filter_kwargs(callable, kwargs)
119
- params = callable.parameters
120
- return kwargs if params.any? { |type, _| type == :keyrest }
121
-
122
- accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
123
- kwargs.slice(*accepted)
124
- end
125
-
126
- def shape_check!(event, required, blk)
127
- provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
128
- return if provided.any? { |t, _| t == :keyrest }
129
-
130
- missing = required - provided.map { |_, n| n }
131
- return if missing.empty?
132
-
133
- raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
134
- end
135
-
136
124
  def match?(globs, key)
137
125
  return true if globs.nil?
138
126
 
@@ -20,7 +20,10 @@ module Textus
20
20
  raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
21
21
 
22
22
  required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
23
- shape_check!(event_sym, required, blk)
23
+ sig = Signature.new(blk)
24
+ missing = sig.missing(required)
25
+ raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
26
+
24
27
  name = name.to_sym
25
28
  raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
26
29
 
@@ -33,45 +36,16 @@ module Textus
33
36
  @table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
34
37
  end
35
38
 
36
- # Invoke a registered callable, injecting `caps:` under the kwarg name
37
- # the callable declares. Legacy `store:` is rejected (no shim).
39
+ # Invoke a registered callable, injecting `caps:` only if the callable
40
+ # declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
41
+ # `caps:`-alternative) are rejected at registration time, not here.
38
42
  def invoke(event, name, caps:, **other)
39
43
  blk = callable(event, name)
40
- params = blk.parameters
41
- accepts_keyrest = params.any? { |t, _| t == :keyrest }
42
- declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
43
-
44
- if declared.include?(:store)
45
- raise UsageError.new(
46
- "RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
47
- "(Textus::Container)",
48
- )
49
- end
50
-
44
+ sig = Signature.new(blk)
51
45
  kwargs = other.dup
52
- kwargs[:caps] = caps if accepts_keyrest || declared.include?(:caps)
46
+ kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
53
47
  blk.call(**kwargs)
54
48
  end
55
-
56
- private
57
-
58
- def shape_check!(event, required, blk)
59
- provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
60
- return if provided.any? { |t, _| t == :keyrest }
61
-
62
- param_names = provided.map { |_, n| n }
63
- # Allow `store:` as a stand-in for `caps:` so registration succeeds;
64
- # invoke will raise UsageError when the callable is actually called.
65
- effective_required = if param_names.include?(:store)
66
- required.map { |r| r == :caps ? :store : r }
67
- else
68
- required
69
- end
70
- missing = effective_required - param_names
71
- return if missing.empty?
72
-
73
- raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
74
- end
75
49
  end
76
50
  end
77
51
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ class Signature
6
+ def initialize(callable)
7
+ @params = callable.parameters
8
+ end
9
+
10
+ def accepts_keyrest?
11
+ @params.any? { |type, _| type == :keyrest }
12
+ end
13
+
14
+ def declared_keys
15
+ @params.each_with_object([]) { |(t, n), acc| acc << n if %i[keyreq key].include?(t) }
16
+ end
17
+
18
+ def missing(required)
19
+ return [] if accepts_keyrest?
20
+
21
+ required - declared_keys
22
+ end
23
+
24
+ def filter(kwargs)
25
+ return kwargs if accepts_keyrest?
26
+
27
+ kwargs.slice(*declared_keys)
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/textus/init.rb CHANGED
@@ -2,19 +2,25 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Init
5
- ZONES = %w[identity working intake review output].freeze
5
+ ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
6
6
 
7
7
  DEFAULT_MANIFEST = <<~YAML
8
8
  version: textus/3
9
+ roles:
10
+ - { name: human, can: [author, propose] }
11
+ - { name: agent, can: [propose, keep] }
12
+ - { name: automation, can: [fetch, build] }
9
13
  zones:
10
- - { name: identity, write_policy: [human], read_policy: [all] }
11
- - { name: working, write_policy: [human, agent, runner], read_policy: [all] }
12
- - { name: intake, write_policy: [runner], read_policy: [all] }
13
- - { name: review, write_policy: [agent, human], read_policy: [all] }
14
- - { name: output, write_policy: [builder], read_policy: [all] }
14
+ - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
15
+ - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
16
+ - { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
17
+ - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
18
+ - { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
15
19
  entries:
16
- - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
17
- - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
20
+ - { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
21
+ - { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
+ - { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
+ - { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
18
24
  YAML
19
25
 
20
26
  HOOKS_README = <<~MD
@@ -30,12 +36,12 @@ module Textus
30
36
  ```ruby
31
37
  Textus.hook do |reg|
32
38
  reg.on(:resolve_intake, :my_source) do |config:, args:, **|
33
- { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
39
+ { _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
34
40
  end
35
41
 
36
42
  reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
37
- reg.on(:validate, :my_check) { |store:, **| [] }
38
- reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
43
+ reg.on(:validate, :my_check) { |caps:, **| [] }
44
+ reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
39
45
 
40
46
  # Run a side-effect every time textus writes a file to your repo:
41
47
  reg.on(:file_published, :notify) do |key:, target:, **|
@@ -50,25 +56,25 @@ module Textus
50
56
 
51
57
  ```yaml
52
58
  entries:
53
- - key: intake.foo
59
+ - key: feeds.foo
54
60
  kind: intake
55
- path: intake/foo.md
56
- zone: intake
61
+ path: feeds/foo.md
62
+ zone: feeds
57
63
  intake:
58
64
  handler: my_source
59
65
 
60
66
  rules:
61
- - match: intake.foo
62
- refresh:
67
+ - match: feeds.foo
68
+ fetch:
63
69
  ttl: 10m
64
70
  on_stale: timed_sync # warn | sync | timed_sync (default: warn)
65
71
  ```
66
72
 
67
73
  Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
68
- :entry_put, :entry_deleted, :entry_refreshed, :entry_renamed,
74
+ :entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
69
75
  :build_completed, :proposal_accepted, :proposal_rejected,
70
76
  :file_published, :store_loaded,
71
- :refresh_started, :refresh_failed, :refresh_backgrounded (pub-sub — return discarded)
77
+ :fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
72
78
 
73
79
  See SPEC.md §5.10 for the full table.
74
80
  MD
@@ -15,7 +15,7 @@ module Textus
15
15
 
16
16
  def call(from:, to:, dry_run: false)
17
17
  raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
18
- raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.zones.key?(from)
18
+ raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
19
19
 
20
20
  dest_dir = File.join(@root, "zones", to)
21
21
  raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)