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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +2 -241
- data/CHANGELOG.md +221 -0
- data/README.md +89 -69
- data/SPEC.md +359 -212
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +122 -87
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +8 -3
- data/lib/textus/cli/verb/propose.rb +28 -0
- data/lib/textus/cli/verb/pulse.rb +12 -3
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/rule_list.rb +7 -7
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/cli.rb +2 -2
- data/lib/textus/container.rb +1 -2
- data/lib/textus/contract.rb +106 -0
- data/lib/textus/cursor_store.rb +24 -0
- data/lib/textus/dispatcher.rb +6 -4
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +8 -8
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +15 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +1 -1
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +4 -4
- data/lib/textus/init.rb +27 -18
- data/lib/textus/layout.rb +41 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
- data/lib/textus/maintenance/migrate.rb +9 -0
- data/lib/textus/maintenance/rule_lint.rb +8 -0
- data/lib/textus/maintenance/zone_mv.rb +11 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +14 -10
- data/lib/textus/manifest/policy.rb +37 -21
- data/lib/textus/manifest/rules.rb +16 -14
- data/lib/textus/manifest/schema.rb +48 -58
- data/lib/textus/manifest.rb +3 -3
- data/lib/textus/mcp/catalog.rb +72 -0
- data/lib/textus/mcp/server.rb +8 -5
- data/lib/textus/mcp/session.rb +3 -20
- data/lib/textus/mcp/tool_schemas.rb +6 -62
- data/lib/textus/mcp/tools.rb +4 -119
- data/lib/textus/ports/audit_log.rb +17 -15
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/build_lock.rb +1 -2
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +2 -2
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/audit.rb +3 -3
- data/lib/textus/read/boot.rb +6 -0
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +16 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/list.rb +8 -0
- data/lib/textus/read/policy_explain.rb +14 -10
- data/lib/textus/read/pulse.rb +12 -4
- data/lib/textus/read/rules.rb +24 -0
- data/lib/textus/read/schema_envelope.rb +7 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role.rb +6 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/session.rb +24 -0
- data/lib/textus/store.rb +11 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +14 -2
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +14 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +29 -14
- data/lib/textus/write/mv.rb +15 -3
- data/lib/textus/write/propose.rb +46 -0
- data/lib/textus/write/put.rb +26 -2
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus.rb +4 -0
- metadata +36 -21
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -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 =
|
|
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.
|
|
91
|
-
authority = store.manifest.policy.
|
|
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
|
|
96
|
-
"none declared (add e.g. `- { name: owner,
|
|
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
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/accept.rb
CHANGED
|
@@ -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
|
|
10
|
-
@call
|
|
11
|
-
@manifest
|
|
12
|
-
@schemas
|
|
13
|
-
@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
@
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
data/lib/textus/write/delete.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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::
|
|
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
|
-
|
|
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
|
|
30
|
+
if reason.to_s.match?(/ttl exceeded|never fetched/)
|
|
23
31
|
begin
|
|
24
32
|
worker.run(key)
|
|
25
|
-
|
|
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
|
-
"
|
|
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
|
|
4
|
-
# Collaborator (not a Dispatcher verb): constructed directly by
|
|
5
|
-
#
|
|
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
|
|
18
|
-
when Textus::Domain::Action::
|
|
19
|
-
when Textus::Domain::Action::
|
|
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::
|
|
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::
|
|
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
|
-
"
|
|
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::
|
|
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::
|
|
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(:
|
|
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::
|
|
93
|
+
Textus::Domain::Outcome::Fetched.new(envelope: result)
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def default_spawner
|
|
98
|
-
Textus::Ports::
|
|
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
|
|
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
|
|
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&.
|
|
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(:
|
|
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(:
|
|
84
|
-
|
|
85
|
-
|
|
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(:
|
|
89
|
-
|
|
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(:
|
|
93
|
-
|
|
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
|
-
|
|
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(:
|
|
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
|
|
data/lib/textus/write/mv.rb
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
data/lib/textus/write/put.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/textus/write/reject.rb
CHANGED
|
@@ -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
|
-
|
|
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 = []
|