textus 0.30.0 → 0.38.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +2 -241
  3. data/CHANGELOG.md +221 -0
  4. data/README.md +89 -69
  5. data/SPEC.md +359 -212
  6. data/docs/conventions.md +42 -37
  7. data/lib/textus/boot.rb +122 -87
  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/hooks.rb +1 -1
  14. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  15. data/lib/textus/cli/verb/propose.rb +28 -0
  16. data/lib/textus/cli/verb/pulse.rb +12 -3
  17. data/lib/textus/cli/verb/put.rb +1 -1
  18. data/lib/textus/cli/verb/rule_list.rb +7 -7
  19. data/lib/textus/cli/verb/schema.rb +1 -1
  20. data/lib/textus/cli/verb.rb +3 -2
  21. data/lib/textus/cli.rb +2 -2
  22. data/lib/textus/container.rb +1 -2
  23. data/lib/textus/contract.rb +106 -0
  24. data/lib/textus/cursor_store.rb +24 -0
  25. data/lib/textus/dispatcher.rb +6 -4
  26. data/lib/textus/doctor/check/audit_log.rb +1 -1
  27. data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
  28. data/lib/textus/doctor/check/proposal_targets.rb +45 -0
  29. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  30. data/lib/textus/doctor.rb +2 -1
  31. data/lib/textus/domain/action.rb +3 -3
  32. data/lib/textus/domain/freshness/evaluator.rb +3 -3
  33. data/lib/textus/domain/freshness/policy.rb +2 -2
  34. data/lib/textus/domain/freshness.rb +7 -7
  35. data/lib/textus/domain/outcome.rb +2 -2
  36. data/lib/textus/domain/permission.rb +2 -10
  37. data/lib/textus/domain/policy/base_guards.rb +25 -0
  38. data/lib/textus/domain/policy/evaluation.rb +15 -0
  39. data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
  40. data/lib/textus/domain/policy/guard.rb +35 -0
  41. data/lib/textus/domain/policy/guard_factory.rb +40 -0
  42. data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
  43. data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
  44. data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
  45. data/lib/textus/domain/policy/predicates/registry.rb +39 -0
  46. data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
  47. data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
  48. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
  49. data/lib/textus/domain/staleness/intake_check.rb +6 -6
  50. data/lib/textus/envelope.rb +2 -2
  51. data/lib/textus/errors.rb +25 -28
  52. data/lib/textus/hooks/event_bus.rb +4 -4
  53. data/lib/textus/init.rb +27 -18
  54. data/lib/textus/layout.rb +41 -0
  55. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  56. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  57. data/lib/textus/maintenance/migrate.rb +9 -0
  58. data/lib/textus/maintenance/rule_lint.rb +8 -0
  59. data/lib/textus/maintenance/zone_mv.rb +11 -1
  60. data/lib/textus/manifest/capabilities.rb +29 -0
  61. data/lib/textus/manifest/data.rb +14 -10
  62. data/lib/textus/manifest/policy.rb +37 -21
  63. data/lib/textus/manifest/rules.rb +16 -14
  64. data/lib/textus/manifest/schema.rb +48 -58
  65. data/lib/textus/manifest.rb +3 -3
  66. data/lib/textus/mcp/catalog.rb +72 -0
  67. data/lib/textus/mcp/server.rb +8 -5
  68. data/lib/textus/mcp/session.rb +3 -20
  69. data/lib/textus/mcp/tool_schemas.rb +6 -62
  70. data/lib/textus/mcp/tools.rb +4 -119
  71. data/lib/textus/ports/audit_log.rb +17 -15
  72. data/lib/textus/ports/audit_subscriber.rb +1 -1
  73. data/lib/textus/ports/build_lock.rb +1 -2
  74. data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
  75. data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/read/audit.rb +3 -3
  78. data/lib/textus/read/boot.rb +6 -0
  79. data/lib/textus/read/freshness.rb +9 -9
  80. data/lib/textus/read/get.rb +16 -8
  81. data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
  82. data/lib/textus/read/list.rb +8 -0
  83. data/lib/textus/read/policy_explain.rb +14 -10
  84. data/lib/textus/read/pulse.rb +12 -4
  85. data/lib/textus/read/rules.rb +24 -0
  86. data/lib/textus/read/schema_envelope.rb +7 -0
  87. data/lib/textus/read/validator.rb +1 -1
  88. data/lib/textus/role.rb +6 -2
  89. data/lib/textus/schema/tools.rb +5 -5
  90. data/lib/textus/session.rb +24 -0
  91. data/lib/textus/store.rb +11 -0
  92. data/lib/textus/version.rb +1 -1
  93. data/lib/textus/write/accept.rb +19 -55
  94. data/lib/textus/write/delete.rb +14 -2
  95. data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
  96. data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
  97. data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
  98. data/lib/textus/write/mv.rb +15 -3
  99. data/lib/textus/write/propose.rb +46 -0
  100. data/lib/textus/write/put.rb +26 -2
  101. data/lib/textus/write/reject.rb +11 -5
  102. data/lib/textus.rb +4 -0
  103. metadata +36 -21
  104. data/lib/textus/cli/verb/refresh.rb +0 -14
  105. data/lib/textus/domain/authorizer.rb +0 -37
  106. data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
  107. data/lib/textus/domain/policy/promote.rb +0 -26
  108. data/lib/textus/domain/policy/promotion.rb +0 -57
  109. data/lib/textus/manifest/role_kinds.rb +0 -21
  110. data/lib/textus/write/authority_gate.rb +0 -24
@@ -49,7 +49,7 @@ module Textus
49
49
  end
50
50
  raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
51
51
 
52
- authority = accept_authority_for(store)
52
+ authority = accept_role_for(store)
53
53
  ops = store.as(authority)
54
54
  touched = []
55
55
  store.manifest.resolver.enumerate.each do |row|
@@ -87,13 +87,13 @@ module Textus
87
87
  raise UsageError.new("schema not found: #{name}")
88
88
  end
89
89
 
90
- def self.accept_authority_for(store)
91
- authority = store.manifest.policy.roles_with_kind(:accept_authority).first
90
+ def self.accept_role_for(store)
91
+ authority = store.manifest.policy.roles_with_capability("author").first
92
92
  return authority if authority
93
93
 
94
94
  raise UsageError.new(
95
- "schema migrate requires a role with kind :accept_authority in the manifest; " \
96
- "none declared (add e.g. `- { name: owner, kind: accept_authority }` to roles:)",
95
+ "schema migrate requires a role holding the 'author' capability; " \
96
+ "none declared (add e.g. `- { name: owner, can: [author] }` to roles:)",
97
97
  )
98
98
  end
99
99
  end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ # The agent session: per-connection (MCP), per-process (CLI), or per-loop
3
+ # (Ruby) orientation state — the audit cursor plus the manifest etag and
4
+ # propose_zone captured at boot. Immutable Data value; advance_cursor
5
+ # returns a new instance. ADR 0036.
6
+ Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
7
+ def advance_cursor(new_cursor) = with(cursor: new_cursor)
8
+
9
+ def check_etag!(observed_etag)
10
+ return if observed_etag == manifest_etag
11
+
12
+ raise Textus::MCP::ContractDrift.new(
13
+ "manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
14
+ )
15
+ end
16
+
17
+ private
18
+
19
+ # First 8 hex chars after the "sha256:" prefix — a stable short id for
20
+ # the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
21
+ # a no-op when the prefix is absent).
22
+ def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
23
+ end
24
+ end
data/lib/textus/store.rb CHANGED
@@ -50,6 +50,17 @@ module Textus
50
50
  @container ||= Textus::Container.from_store(self)
51
51
  end
52
52
 
53
+ # Build an agent Session oriented at the current cursor/manifest — the
54
+ # Ruby equivalent of an MCP `initialize`. ADR 0036.
55
+ def session(role:)
56
+ Textus::Session.new(
57
+ role: role,
58
+ cursor: audit_log.latest_seq,
59
+ propose_zone: manifest.policy.propose_zone_for(role),
60
+ manifest_etag: file_store.etag(File.join(root, "manifest.yaml")),
61
+ )
62
+ end
63
+
53
64
  def as(role, dry_run: false, correlation_id: nil)
54
65
  RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
55
66
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.30.0"
2
+ VERSION = "0.38.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -1,37 +1,30 @@
1
- require_relative "authority_gate"
2
-
3
1
  module Textus
4
2
  module Write
5
3
  class Accept
6
- include AuthorityGate
7
-
8
4
  def initialize(container:, call:)
9
- @container = container
10
- @call = call
11
- @manifest = container.manifest
12
- @schemas = container.schemas
13
- @events = container.events
5
+ @container = container
6
+ @call = call
7
+ @manifest = container.manifest
8
+ @schemas = container.schemas
9
+ @events = container.events
14
10
  end
15
11
 
16
12
  def call(pending_key)
17
- assert_accept_authority!("accept")
18
-
19
- env = Textus::Read::Get.new(
20
- container: @container, call: @call,
21
- ).call(pending_key)
13
+ env = Textus::Read::Get.new(container: @container, call: @call).call(pending_key)
22
14
  proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
23
15
  target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
24
16
  action = proposal["action"] || "put"
25
17
 
26
- evaluate_promotion!(env, target)
18
+ guard.for(:accept, target).check!(
19
+ Textus::Domain::Policy::Evaluation.new(
20
+ actor: @call.role, transition: :accept, origin: pending_key,
21
+ target: target, envelope: env, manifest: @manifest
22
+ ),
23
+ )
27
24
 
28
25
  case action
29
26
  when "put"
30
- # Nested proposal "frontmatter" the meta to write to the accepted
31
- # target. Not related to the removed intake-handler legacy bridge.
32
- target_meta = env.meta["frontmatter"] || {}
33
- target_body = env.body
34
- put_op.call(target, meta: target_meta, body: target_body)
27
+ put_op.call(target, meta: env.meta["frontmatter"] || {}, body: env.body)
35
28
  when "delete"
36
29
  delete_op.call(target)
37
30
  else
@@ -39,48 +32,19 @@ module Textus
39
32
  end
40
33
 
41
34
  delete_op.call(pending_key)
42
-
43
- @events.publish(:proposal_accepted,
44
- ctx: hook_context,
45
- key: pending_key,
46
- target_key: target)
47
-
35
+ @events.publish(:proposal_accepted, ctx: hook_context, key: pending_key, target_key: target)
48
36
  { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
49
37
  end
50
38
 
51
39
  private
52
40
 
53
- def hook_context
54
- @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
55
- end
56
-
57
- def put_op
58
- @put_op ||= Textus::Write::Put.new(
59
- container: @container, call: @call,
60
- )
41
+ def guard
42
+ @guard ||= Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
61
43
  end
62
44
 
63
- def delete_op
64
- @delete_op ||= Textus::Write::Delete.new(
65
- container: @container, call: @call,
66
- )
67
- end
68
-
69
- def evaluate_promotion!(env, target_key)
70
- rules = @manifest.rules.for(target_key)
71
- promote = rules.promote
72
- return if promote.nil? || promote.requires.empty?
73
-
74
- policy = Textus::Domain::Policy::Promotion.from_names(promote.requires)
75
- result = policy.evaluate(
76
- entry: env, schemas: @schemas, manifest: @manifest, role: @call.role,
77
- )
78
- return if result.ok?
79
-
80
- raise ProposalError.new(
81
- "promotion gate failed: #{result.reasons.join("; ")}",
82
- )
83
- end
45
+ def hook_context = @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
46
+ def put_op = @put_op ||= Textus::Write::Put.new(container: @container, call: @call)
47
+ def delete_op = @delete_op ||= Textus::Write::Delete.new(container: @container, call: @call)
84
48
  end
85
49
  end
86
50
  end
@@ -5,7 +5,6 @@ module Textus
5
5
  @container = container
6
6
  @call = call
7
7
  @manifest = container.manifest
8
- @authorizer = container.authorizer
9
8
  @events = container.events
10
9
  end
11
10
 
@@ -13,7 +12,7 @@ module Textus
13
12
  Textus::Manifest::Data.validate_key!(key)
14
13
  mentry = @manifest.resolver.resolve(key).entry
15
14
 
16
- @authorizer.authorize_write!(mentry, role: @call.role)
15
+ guard_for(:delete, key, if_etag: if_etag).check!(eval_for(:delete, target_key: key))
17
16
 
18
17
  writer.delete(key, mentry: mentry, if_etag: if_etag)
19
18
 
@@ -28,6 +27,19 @@ module Textus
28
27
 
29
28
  private
30
29
 
30
+ def guard_for(transition, key, if_etag: nil)
31
+ Textus::Domain::Policy::GuardFactory.new(
32
+ manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
33
+ ).for(transition, key)
34
+ end
35
+
36
+ def eval_for(transition, target_key:, envelope: nil)
37
+ Textus::Domain::Policy::Evaluation.new(
38
+ actor: @call.role, transition: transition, origin: nil,
39
+ target: target_key, envelope: envelope, manifest: @manifest
40
+ )
41
+ end
42
+
31
43
  def hook_context
32
44
  @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
33
45
  end
@@ -1,28 +1,36 @@
1
1
  module Textus
2
2
  module Write
3
- class RefreshAll
3
+ class FetchAll
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :fetch_all
7
+ summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :prefix, String
10
+ arg :zone, String
11
+
4
12
  def initialize(container:, call:)
5
13
  @container = container
6
14
  @call = call
7
15
  end
8
16
 
9
17
  def call(prefix: nil, zone: nil)
10
- worker = Textus::Write::RefreshWorker.new(
18
+ worker = Textus::Write::FetchWorker.new(
11
19
  container: @container, call: @call,
12
20
  )
13
21
 
14
22
  stale_rows = Textus::Read::Stale.new(container: @container, call: @call).call(prefix: prefix, zone: zone)
15
- refreshed = []
23
+ fetched = []
16
24
  failed = []
17
25
  skipped = []
18
26
 
19
27
  stale_rows.each do |row|
20
28
  key = row["key"] || row[:key]
21
29
  reason = row["reason"] || row[:reason]
22
- if reason.to_s.match?(/ttl exceeded|never refreshed/)
30
+ if reason.to_s.match?(/ttl exceeded|never fetched/)
23
31
  begin
24
32
  worker.run(key)
25
- refreshed << key
33
+ fetched << key
26
34
  rescue Textus::Error => e
27
35
  failed << { "key" => key, "error" => e.message }
28
36
  end
@@ -34,7 +42,7 @@ module Textus
34
42
  {
35
43
  "protocol" => Textus::PROTOCOL,
36
44
  "ok" => failed.empty?,
37
- "refreshed" => refreshed,
45
+ "fetched" => fetched,
38
46
  "failed" => failed,
39
47
  "skipped" => skipped,
40
48
  }
@@ -1,8 +1,8 @@
1
1
  module Textus
2
2
  module Write
3
- class RefreshOrchestrator
4
- # Collaborator (not a Dispatcher verb): constructed directly by RefreshWorker /
5
- # GetOrRefresh, which pass their derived hook_context in. That's why this takes
3
+ class FetchOrchestrator
4
+ # Collaborator (not a Dispatcher verb): constructed directly by FetchWorker /
5
+ # GetOrFetch, which pass their derived hook_context in. That's why this takes
6
6
  # hook_context: explicitly while verb use cases derive their own.
7
7
  def initialize(worker:, store_root:, events:, hook_context: nil, detached_spawner: nil)
8
8
  @worker = worker
@@ -14,9 +14,9 @@ module Textus
14
14
 
15
15
  def execute(action, key:)
16
16
  case action
17
- when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
18
- when Textus::Domain::Action::RefreshSync then run_sync(key)
19
- when Textus::Domain::Action::RefreshTimed then run_timed(action.budget_ms, key)
17
+ when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
18
+ when Textus::Domain::Action::FetchSync then run_sync(key)
19
+ when Textus::Domain::Action::FetchTimed then run_timed(action.budget_ms, key)
20
20
  else raise ArgumentError.new("unknown action: #{action.inspect}")
21
21
  end
22
22
  end
@@ -25,13 +25,13 @@ module Textus
25
25
 
26
26
  def run_sync(key)
27
27
  envelope = @worker.run(key)
28
- Textus::Domain::Outcome::Refreshed.new(envelope: envelope)
28
+ Textus::Domain::Outcome::Fetched.new(envelope: envelope)
29
29
  rescue Textus::Error => e
30
30
  Textus::Domain::Outcome::Failed.new(error: e)
31
31
  end
32
32
 
33
33
  def run_timed(budget_ms, key)
34
- return run_timed_with_fork(budget_ms, key) if Textus::Ports::Refresh::Detached.supported?
34
+ return run_timed_with_fork(budget_ms, key) if Textus::Ports::Fetch::Detached.supported?
35
35
 
36
36
  run_timed_cooperative(budget_ms, key)
37
37
  end
@@ -49,7 +49,7 @@ module Textus
49
49
  thread.kill
50
50
  return Textus::Domain::Outcome::Failed.new(
51
51
  error: Textus::UsageError.new(
52
- "refresh exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
52
+ "fetch exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
53
53
  ),
54
54
  )
55
55
  end
@@ -57,7 +57,7 @@ module Textus
57
57
  if result.is_a?(Textus::Error)
58
58
  Textus::Domain::Outcome::Failed.new(error: result)
59
59
  else
60
- Textus::Domain::Outcome::Refreshed.new(envelope: result)
60
+ Textus::Domain::Outcome::Fetched.new(envelope: result)
61
61
  end
62
62
  end
63
63
 
@@ -77,25 +77,25 @@ module Textus
77
77
  # Single-flight: if a sibling process / earlier fork holds the
78
78
  # per-leaf lock, don't fork another worker — they're already
79
79
  # doing this work.
80
- probe = Textus::Ports::Refresh::Lock.new(root: @store_root, key: key)
80
+ probe = Textus::Ports::Fetch::Lock.new(root: @store_root, key: key)
81
81
  return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
82
82
 
83
83
  probe.release
84
84
 
85
85
  payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
86
86
  payload[:ctx] = @hook_context if @hook_context
87
- @events.publish(:refresh_backgrounded, **payload)
87
+ @events.publish(:fetch_backgrounded, **payload)
88
88
  @detached_spawner.call(store_root: @store_root, key: key)
89
89
  Textus::Domain::Outcome::Detached.new
90
90
  elsif result.is_a?(Textus::Error)
91
91
  Textus::Domain::Outcome::Failed.new(error: result)
92
92
  else
93
- Textus::Domain::Outcome::Refreshed.new(envelope: result)
93
+ Textus::Domain::Outcome::Fetched.new(envelope: result)
94
94
  end
95
95
  end
96
96
 
97
97
  def default_spawner
98
- Textus::Ports::Refresh::Detached.method(:spawn)
98
+ Textus::Ports::Fetch::Detached.method(:spawn)
99
99
  end
100
100
  end
101
101
  end
@@ -2,20 +2,28 @@ require "timeout"
2
2
 
3
3
  module Textus
4
4
  module Write
5
- class RefreshWorker
5
+ class FetchWorker
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :fetch
9
+ summary "Run a fetch action for one quarantine entry."
10
+ surfaces :cli, :ruby, :mcp
11
+ arg :key, String, required: true, positional: true
12
+ response { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
13
+
6
14
  FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
7
15
 
8
16
  def initialize(container:, call:)
9
17
  @container = container
10
18
  @call = call
11
19
  @manifest = container.manifest
20
+ @schemas = container.schemas
12
21
  @events = container.events
13
22
  @rpc = container.rpc
14
- @authorizer = container.authorizer
15
23
  end
16
24
 
17
25
  # call(key) is the primary entry; run is kept as an alias for
18
- # Orchestrator and RefreshAll which call worker.run(key).
26
+ # Orchestrator and FetchAll which call worker.run(key).
19
27
  def call(key)
20
28
  run(key)
21
29
  end
@@ -63,11 +71,11 @@ module Textus
63
71
 
64
72
  def fetch_timeout_for(key)
65
73
  rule = @manifest.rules.for(key)
66
- rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
74
+ rule&.fetch&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
67
75
  end
68
76
 
69
77
  def fetch_with_events(key, mentry, remaining)
70
- @events.publish(:refresh_started, ctx: hook_context, key: key, mode: :sync)
78
+ @events.publish(:fetch_started, ctx: hook_context, key: key, mode: :sync)
71
79
  call_intake(key, mentry, remaining)
72
80
  end
73
81
 
@@ -80,23 +88,30 @@ module Textus
80
88
  args: { trigger_key: key, leaf_segments: remaining || [] })
81
89
  end
82
90
  rescue Timeout::Error
83
- @events.publish(:refresh_failed, ctx: hook_context, key: key,
84
- error_class: "Timeout::Error",
85
- error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
91
+ @events.publish(:fetch_failed, ctx: hook_context, key: key,
92
+ error_class: "Timeout::Error",
93
+ error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
86
94
  raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
87
95
  rescue Textus::Error => e
88
- @events.publish(:refresh_failed, ctx: hook_context, key: key, error_class: e.class.name,
89
- error_message: e.message)
96
+ @events.publish(:fetch_failed, ctx: hook_context, key: key, error_class: e.class.name,
97
+ error_message: e.message)
90
98
  raise
91
99
  rescue StandardError => e
92
- @events.publish(:refresh_failed, ctx: hook_context, key: key, error_class: e.class.name,
93
- error_message: e.message)
100
+ @events.publish(:fetch_failed, ctx: hook_context, key: key, error_class: e.class.name,
101
+ error_message: e.message)
94
102
  raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
95
103
  end
96
104
 
97
105
  def persist_and_notify(key, mentry, result, before_etag)
98
106
  normalized = self.class.normalize_action_result(result, format: mentry.format)
99
- @authorizer.authorize_write!(mentry, role: @call.role)
107
+ Textus::Domain::Policy::GuardFactory.new(
108
+ manifest: @manifest, schemas: @schemas,
109
+ ).for(:fetch, key).check!(
110
+ Textus::Domain::Policy::Evaluation.new(
111
+ actor: @call.role, transition: :fetch, origin: nil,
112
+ target: key, envelope: nil, manifest: @manifest
113
+ ),
114
+ )
100
115
  envelope = writer.put(
101
116
  key,
102
117
  mentry: mentry,
@@ -105,7 +120,7 @@ module Textus
105
120
  ),
106
121
  )
107
122
  change = detect_change(before_etag, envelope)
108
- @events.publish(:entry_refreshed, ctx: hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
123
+ @events.publish(:entry_fetched, ctx: hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
109
124
  envelope
110
125
  end
111
126
 
@@ -6,7 +6,6 @@ module Textus
6
6
  @call = call
7
7
  @manifest = container.manifest
8
8
  @events = container.events
9
- @authorizer = container.authorizer
10
9
  end
11
10
 
12
11
  def call(old_key, new_key, dry_run: false)
@@ -38,8 +37,8 @@ module Textus
38
37
  raise UnknownKey.new(old_key) unless reader.exists?(old_key)
39
38
 
40
39
  validate_zone_and_format!(old_res.entry, new_res.entry)
41
- @authorizer.authorize_write!(old_res.entry, role: @call.role)
42
- @authorizer.authorize_write!(new_res.entry, role: @call.role)
40
+ guard_for(:mv, old_key).check!(eval_for(:mv, target_key: old_key))
41
+ guard_for(:mv, new_key).check!(eval_for(:mv, target_key: new_key))
43
42
  raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if reader.exists?(new_key)
44
43
 
45
44
  [old_res, new_res]
@@ -101,6 +100,19 @@ module Textus
101
100
  }
102
101
  end
103
102
 
103
+ def guard_for(transition, key, if_etag: nil)
104
+ Textus::Domain::Policy::GuardFactory.new(
105
+ manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
106
+ ).for(transition, key)
107
+ end
108
+
109
+ def eval_for(transition, target_key:, envelope: nil)
110
+ Textus::Domain::Policy::Evaluation.new(
111
+ actor: @call.role, transition: transition, origin: nil,
112
+ target: target_key, envelope: envelope, manifest: @manifest
113
+ )
114
+ end
115
+
104
116
  def writer
105
117
  @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
106
118
  end
@@ -0,0 +1,46 @@
1
+ module Textus
2
+ module Write
3
+ # Queue a proposal: resolve the acting role's propose_zone, prefix the key,
4
+ # and write there via the Put verb. Was inlined in the MCP `propose` tool
5
+ # and the CLI propose verb; promoted to a first-class verb so all three
6
+ # transports share one implementation (ADR 0036, ADR 0039).
7
+ class Propose
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :propose
11
+ summary "Write a proposal to the role's propose_zone. Auto-prefixes the key."
12
+ surfaces :cli, :ruby, :mcp
13
+ arg :key, String, required: true, positional: true,
14
+ description: "key relative to propose_zone, e.g. 'decisions.feature-x'"
15
+ arg :meta, Hash, required: true
16
+ arg :body, String
17
+ arg :content, Hash
18
+ response { |env| { "uid" => env.uid, "etag" => env.etag, "key" => env.key } }
19
+
20
+ def initialize(container:, call:)
21
+ @container = container
22
+ @call = call
23
+ @manifest = container.manifest
24
+ end
25
+
26
+ # if_etag is intentionally absent: a proposal is always a fresh queue write.
27
+ def call(key, meta: nil, body: nil, content: nil)
28
+ zone = @manifest.policy.propose_zone_for(@call.role)
29
+ unless zone
30
+ raise Textus::Error.new(
31
+ "propose_forbidden",
32
+ "role '#{@call.role}' has no writable propose_zone",
33
+ details: { "role" => @call.role },
34
+ hint: "the manifest must define a queue zone and '#{@call.role}' must hold the 'propose' capability",
35
+ )
36
+ end
37
+
38
+ Textus::Dispatcher.invoke(
39
+ :put, container: @container, call: @call,
40
+ args: ["#{zone}.#{key}"],
41
+ kwargs: { meta: meta || {}, body: body, content: content }
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,18 +1,29 @@
1
1
  module Textus
2
2
  module Write
3
3
  class Put
4
+ extend Textus::Contract::DSL
5
+
6
+ verb :put
7
+ summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
8
+ surfaces :cli, :ruby, :mcp
9
+ arg :key, String, required: true, positional: true
10
+ arg :meta, Hash, required: true
11
+ arg :body, String
12
+ arg :content, Hash
13
+ arg :if_etag, String
14
+ response { |env| { "uid" => env.uid, "etag" => env.etag } }
15
+
4
16
  def initialize(container:, call:)
5
17
  @container = container
6
18
  @call = call
7
19
  @manifest = container.manifest
8
- @authorizer = container.authorizer
9
20
  @events = container.events
10
21
  end
11
22
 
12
23
  def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
13
24
  Textus::Manifest::Data.validate_key!(key)
14
25
  mentry = @manifest.resolver.resolve(key).entry
15
- @authorizer.authorize_write!(mentry, role: @call.role)
26
+ guard_for(:put, key, if_etag: if_etag).check!(eval_for(:put, target_key: key))
16
27
 
17
28
  envelope = writer.put(
18
29
  key,
@@ -33,6 +44,19 @@ module Textus
33
44
 
34
45
  private
35
46
 
47
+ def guard_for(transition, key, if_etag: nil)
48
+ Textus::Domain::Policy::GuardFactory.new(
49
+ manifest: @manifest, schemas: @container.schemas, extra: { if_etag: if_etag },
50
+ ).for(transition, key)
51
+ end
52
+
53
+ def eval_for(transition, target_key:, envelope: nil)
54
+ Textus::Domain::Policy::Evaluation.new(
55
+ actor: @call.role, transition: transition, origin: nil,
56
+ target: target_key, envelope: envelope, manifest: @manifest
57
+ )
58
+ end
59
+
36
60
  def hook_context
37
61
  @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
38
62
  end
@@ -1,19 +1,21 @@
1
- require_relative "authority_gate"
2
-
3
1
  module Textus
4
2
  module Write
5
3
  class Reject
6
- include AuthorityGate
7
-
8
4
  def initialize(container:, call:)
9
5
  @container = container
10
6
  @call = call
11
7
  @manifest = container.manifest
8
+ @schemas = container.schemas
12
9
  @events = container.events
13
10
  end
14
11
 
15
12
  def call(pending_key)
16
- assert_accept_authority!("reject")
13
+ guard.for(:reject, pending_key).check!(
14
+ Textus::Domain::Policy::Evaluation.new(
15
+ actor: @call.role, transition: :reject, origin: pending_key,
16
+ target: pending_key, envelope: nil, manifest: @manifest
17
+ ),
18
+ )
17
19
 
18
20
  mentry = @manifest.resolver.resolve(pending_key).entry
19
21
  unless mentry.in_proposal_zone?(@manifest.policy)
@@ -40,6 +42,10 @@ module Textus
40
42
 
41
43
  private
42
44
 
45
+ def guard
46
+ @guard ||= Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
47
+ end
48
+
43
49
  def hook_context
44
50
  @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
45
51
  end
data/lib/textus.rb CHANGED
@@ -20,6 +20,10 @@ loader.ignore(File.expand_path("textus/mcp/errors.rb", __dir__))
20
20
  loader.setup
21
21
  loader.eager_load
22
22
 
23
+ # Derive CLI_VERBS after eager_load so all contract-declaring files are present
24
+ # (boot.rb loads first alphabetically; Dispatcher contracts are declared later).
25
+ Textus::Boot::CLI_VERBS = Textus::Boot.build_cli_verbs.freeze
26
+
23
27
  module Textus
24
28
  @hook_mutex = Mutex.new
25
29
  @hook_blocks = []