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/cli.rb
CHANGED
|
@@ -90,8 +90,8 @@ module Textus
|
|
|
90
90
|
textus get KEY
|
|
91
91
|
textus put KEY --stdin [--fetch=NAME] --as=ROLE
|
|
92
92
|
textus freshness [--prefix=KEY] [--zone=Z]
|
|
93
|
-
textus
|
|
94
|
-
textus
|
|
93
|
+
textus fetch KEY
|
|
94
|
+
textus fetch stale [--prefix=KEY] [--zone=Z]
|
|
95
95
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
96
96
|
textus blame KEY [--limit=N]
|
|
97
97
|
textus doctor
|
data/lib/textus/container.rb
CHANGED
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
# ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
|
|
4
4
|
Container = Data.define(
|
|
5
5
|
:manifest, :file_store, :schemas, :root,
|
|
6
|
-
:audit_log, :events, :rpc
|
|
6
|
+
:audit_log, :events, :rpc
|
|
7
7
|
)
|
|
8
8
|
|
|
9
9
|
class Container
|
|
@@ -16,7 +16,6 @@ module Textus
|
|
|
16
16
|
audit_log: store.audit_log,
|
|
17
17
|
events: store.events,
|
|
18
18
|
rpc: store.rpc,
|
|
19
|
-
authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest),
|
|
20
19
|
)
|
|
21
20
|
end
|
|
22
21
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Declarative, co-located interface contract for a verb. One source of truth
|
|
3
|
+
# for the agent-facing summary, the argument schema, which transports expose
|
|
4
|
+
# the verb, and how the return value is shaped for the wire. CLI/Ruby/MCP and
|
|
5
|
+
# boot project from this; the MCP catalog is fully derived from it (ADR 0039).
|
|
6
|
+
module Contract
|
|
7
|
+
# One argument of a verb. `positional: true` means it is passed to the
|
|
8
|
+
# use-case as a positional (e.g. `get(key)`); otherwise as a keyword.
|
|
9
|
+
# `session_default` names a zero-arg method on `Textus::Session` (Symbol)
|
|
10
|
+
# that supplies the value when the wire arg is absent; `nil` means no default.
|
|
11
|
+
Arg = Data.define(:name, :type, :required, :positional, :session_default, :description)
|
|
12
|
+
|
|
13
|
+
JSON_TYPES = {
|
|
14
|
+
String => "string", Integer => "integer", Hash => "object",
|
|
15
|
+
Array => "array", :boolean => "boolean"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.json_type(type)
|
|
19
|
+
JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Spec = Data.define(:verb, :summary, :args, :surfaces, :response) do
|
|
23
|
+
def mcp? = surfaces.include?(:mcp)
|
|
24
|
+
|
|
25
|
+
def required_args = args.select(&:required)
|
|
26
|
+
|
|
27
|
+
# JSON-Schema object for MCP tools/list inputSchema.
|
|
28
|
+
# Outer keys (:type, :properties, :required) are symbols; inner property
|
|
29
|
+
# keys are strings — matches the MCP/JSON wire shape expected by clients.
|
|
30
|
+
def input_schema
|
|
31
|
+
props = args.to_h do |a|
|
|
32
|
+
h = { "type" => Contract.json_type(a.type) }
|
|
33
|
+
h["description"] = a.description if a.description
|
|
34
|
+
[a.name.to_s, h]
|
|
35
|
+
end
|
|
36
|
+
{ type: "object", properties: props, required: required_args.map { |a| a.name.to_s } }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Mixed onto a use-case class via `extend`. Calls accumulate into ivars,
|
|
41
|
+
# frozen into a Spec on first read of `.contract`.
|
|
42
|
+
module DSL
|
|
43
|
+
def verb(name = nil)
|
|
44
|
+
if name
|
|
45
|
+
raise "contract already built; declare verb before reading .contract" if defined?(@__contract) && @__contract
|
|
46
|
+
|
|
47
|
+
@__verb = name
|
|
48
|
+
else
|
|
49
|
+
@__verb
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def summary(text = nil)
|
|
54
|
+
if text
|
|
55
|
+
raise "contract already built; declare summary before reading .contract" if defined?(@__contract) && @__contract
|
|
56
|
+
|
|
57
|
+
@__summary = text
|
|
58
|
+
else
|
|
59
|
+
@__summary
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def surfaces(*list)
|
|
64
|
+
if list.empty?
|
|
65
|
+
@__surfaces ||= []
|
|
66
|
+
else
|
|
67
|
+
raise "contract already built; declare surfaces before reading .contract" if defined?(@__contract) && @__contract
|
|
68
|
+
|
|
69
|
+
@__surfaces = list
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def arg(name, type, required: false, positional: false, session_default: nil, description: nil)
|
|
74
|
+
raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
|
|
75
|
+
|
|
76
|
+
(@__args ||= []) << Arg.new(
|
|
77
|
+
name: name, type: type, required: required,
|
|
78
|
+
positional: positional, session_default: session_default, description: description
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def response(&blk)
|
|
83
|
+
@__response = blk if blk
|
|
84
|
+
@__response || ->(v) { v }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def contract?
|
|
88
|
+
!@__verb.nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
|
92
|
+
# @__contract uses double-underscore to match the other accumulator ivars
|
|
93
|
+
# (@__verb, @__args, etc.) and avoid name collision with user-defined `@contract`.
|
|
94
|
+
def contract
|
|
95
|
+
@__contract ||= Spec.new(
|
|
96
|
+
verb: @__verb,
|
|
97
|
+
summary: @__summary,
|
|
98
|
+
args: (@__args || []).freeze,
|
|
99
|
+
surfaces: (@__surfaces || []).freeze,
|
|
100
|
+
response: response,
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
# Per-role cursor cache under <root>/.run/state/cursor.<role>. A convenience so
|
|
5
|
+
# `textus pulse` (no --since) means "since I last looked". Gitignored;
|
|
6
|
+
# losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
|
|
7
|
+
class CursorStore
|
|
8
|
+
def initialize(root:, role:)
|
|
9
|
+
@path = Textus::Layout.cursor(root, role)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def read
|
|
13
|
+
Integer(File.read(@path).strip)
|
|
14
|
+
rescue Errno::ENOENT, ArgumentError
|
|
15
|
+
0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write(seq)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
20
|
+
File.write(@path, seq.to_s)
|
|
21
|
+
seq
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -6,18 +6,19 @@ module Textus
|
|
|
6
6
|
VERBS = {
|
|
7
7
|
# Write
|
|
8
8
|
put: Textus::Write::Put,
|
|
9
|
+
propose: Textus::Write::Propose,
|
|
9
10
|
delete: Textus::Write::Delete,
|
|
10
11
|
mv: Textus::Write::Mv,
|
|
11
12
|
accept: Textus::Write::Accept,
|
|
12
13
|
reject: Textus::Write::Reject,
|
|
13
14
|
publish: Textus::Write::Publish,
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
fetch: Textus::Write::FetchWorker,
|
|
16
|
+
fetch_all: Textus::Write::FetchAll,
|
|
16
17
|
retention_sweep: Textus::Write::RetentionSweep,
|
|
17
18
|
|
|
18
19
|
# Read
|
|
19
20
|
get: Textus::Read::Get,
|
|
20
|
-
|
|
21
|
+
get_or_fetch: Textus::Read::GetOrFetch,
|
|
21
22
|
list: Textus::Read::List,
|
|
22
23
|
where: Textus::Read::Where,
|
|
23
24
|
uid: Textus::Read::Uid,
|
|
@@ -30,11 +31,12 @@ module Textus
|
|
|
30
31
|
pulse: Textus::Read::Pulse,
|
|
31
32
|
policy_explain: Textus::Read::PolicyExplain,
|
|
32
33
|
published: Textus::Read::Published,
|
|
33
|
-
|
|
34
|
+
schema: Textus::Read::SchemaEnvelope,
|
|
34
35
|
validate_all: Textus::Read::ValidateAll,
|
|
35
36
|
doctor: Textus::Read::Doctor,
|
|
36
37
|
boot: Textus::Read::Boot,
|
|
37
38
|
retainable: Textus::Read::Retainable,
|
|
39
|
+
rules: Textus::Read::Rules,
|
|
38
40
|
|
|
39
41
|
# Maintenance
|
|
40
42
|
migrate: Textus::Maintenance::Migrate,
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Doctor
|
|
3
3
|
class Check
|
|
4
|
-
# Lists per-key
|
|
4
|
+
# Lists per-key fetch lock files under <root>/.run/locks/ whose
|
|
5
5
|
# recorded PID is no longer running. These are forensic artifacts only:
|
|
6
|
-
#
|
|
6
|
+
# Fetch::Lock uses flock(2), which the kernel releases on process
|
|
7
7
|
# death, so stale files do not block subsequent acquires. The check
|
|
8
8
|
# exists to let users clean up clutter and notice unexpected accumulation
|
|
9
|
-
# (e.g. a
|
|
10
|
-
class
|
|
9
|
+
# (e.g. a fetch path that crashes repeatedly).
|
|
10
|
+
class FetchLocks < Check
|
|
11
11
|
def call
|
|
12
|
-
dir =
|
|
12
|
+
dir = Textus::Layout.locks(root)
|
|
13
13
|
return [] unless File.directory?(dir)
|
|
14
14
|
|
|
15
15
|
Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
|
|
@@ -23,11 +23,11 @@ module Textus
|
|
|
23
23
|
return nil if pid_alive?(pid)
|
|
24
24
|
|
|
25
25
|
{
|
|
26
|
-
"code" => "
|
|
26
|
+
"code" => "fetch_lock.stale",
|
|
27
27
|
"level" => "info",
|
|
28
28
|
"subject" => path,
|
|
29
|
-
"message" => "
|
|
30
|
-
"(does not block
|
|
29
|
+
"message" => "fetch lock file at #{path} records dead PID #{pid} " \
|
|
30
|
+
"(does not block fetch; flock is kernel-released on exit)",
|
|
31
31
|
"fix" => "safe to delete: rm #{path}",
|
|
32
32
|
}
|
|
33
33
|
rescue Errno::ENOENT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# Flags pending proposals whose `proposal.target_key` cannot ever be
|
|
5
|
+
# accepted: it points at a non-canon zone or resolves to no declared
|
|
6
|
+
# entry (ADR 0035). Reads the live queue zone; silent when there is no
|
|
7
|
+
# queue zone. Warnings, not errors — they are stale junk, not store
|
|
8
|
+
# corruption (the accept gate already refuses them).
|
|
9
|
+
class ProposalTargets < Check
|
|
10
|
+
def call
|
|
11
|
+
queue = manifest.policy.queue_zone
|
|
12
|
+
return [] unless queue
|
|
13
|
+
|
|
14
|
+
dispatch(:list, zone: queue).filter_map { |row| issue_for(row["key"]) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def issue_for(key)
|
|
20
|
+
target = dispatch(:get, key).meta&.dig("proposal", "target_key")
|
|
21
|
+
return nil if target.nil? # not a proposal entry — skip
|
|
22
|
+
|
|
23
|
+
zone = manifest.resolver.resolve(target).entry.zone
|
|
24
|
+
return nil if manifest.policy.declared_kind(zone.to_s) == :canon
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
"code" => "proposal.target_not_canon",
|
|
28
|
+
"level" => "warning",
|
|
29
|
+
"subject" => key,
|
|
30
|
+
"message" => "proposal '#{key}' targets '#{target}' in zone '#{zone}' (not canon); it can never be accepted",
|
|
31
|
+
"fix" => "delete the proposal, or repoint target_key at a canon zone",
|
|
32
|
+
}
|
|
33
|
+
rescue Textus::UnknownKey
|
|
34
|
+
{
|
|
35
|
+
"code" => "proposal.target_unresolved",
|
|
36
|
+
"level" => "warning",
|
|
37
|
+
"subject" => key,
|
|
38
|
+
"message" => "proposal '#{key}' targets '#{target}', which resolves to no declared entry",
|
|
39
|
+
"fix" => "delete the proposal, or fix target_key",
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -2,11 +2,11 @@ module Textus
|
|
|
2
2
|
module Doctor
|
|
3
3
|
class Check
|
|
4
4
|
# Flags entries whose key is matched by two or more rule blocks of the
|
|
5
|
-
# SAME specificity in the same slot (
|
|
6
|
-
#
|
|
5
|
+
# SAME specificity in the same slot (fetch / handler_allowlist /
|
|
6
|
+
# guard). Ties are non-deterministic in the parser's pick step, so
|
|
7
7
|
# they're a configuration smell — surface them.
|
|
8
8
|
class RuleAmbiguity < Check
|
|
9
|
-
SLOTS = %i[
|
|
9
|
+
SLOTS = %i[fetch handler_allowlist guard].freeze
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
12
|
out = []
|
data/lib/textus/doctor.rb
CHANGED
data/lib/textus/domain/action.rb
CHANGED
|
@@ -9,15 +9,15 @@ module Textus
|
|
|
9
9
|
def call(policy, envelope, now:)
|
|
10
10
|
return Verdict.fresh if policy.ttl_seconds.nil?
|
|
11
11
|
|
|
12
|
-
last_str = envelope&.meta&.dig("
|
|
13
|
-
return Verdict.stale("never
|
|
12
|
+
last_str = envelope&.meta&.dig("last_fetched_at")
|
|
13
|
+
return Verdict.stale("never fetched") if last_str.nil?
|
|
14
14
|
|
|
15
15
|
last = begin
|
|
16
16
|
Time.parse(last_str.to_s)
|
|
17
17
|
rescue ArgumentError, TypeError
|
|
18
18
|
nil
|
|
19
19
|
end
|
|
20
|
-
return Verdict.stale("unparseable
|
|
20
|
+
return Verdict.stale("unparseable last_fetched_at: #{last_str.inspect}") if last.nil?
|
|
21
21
|
|
|
22
22
|
age = now - last
|
|
23
23
|
return Verdict.fresh if age <= policy.ttl_seconds
|
|
@@ -7,8 +7,8 @@ module Textus
|
|
|
7
7
|
|
|
8
8
|
case on_stale
|
|
9
9
|
when :warn then Action::Return.new
|
|
10
|
-
when :sync then Action::
|
|
11
|
-
when :timed_sync then Action::
|
|
10
|
+
when :sync then Action::FetchSync.new
|
|
11
|
+
when :timed_sync then Action::FetchTimed.new(budget_ms: sync_budget_ms)
|
|
12
12
|
else Action::Return.new
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -8,19 +8,19 @@ module Textus
|
|
|
8
8
|
#
|
|
9
9
|
# Note on wire format: `#to_h_for_wire` is intentionally narrower than the
|
|
10
10
|
# full field set. It emits the legacy keys ("stale", "stale_reason",
|
|
11
|
-
# "
|
|
11
|
+
# "fetching", and "fetch_error" when present) so the CLI JSON wire
|
|
12
12
|
# stays byte-identical with textus/3. The gem-side fields `checked_at`
|
|
13
13
|
# and `ttl_remaining_ms` are NOT emitted on the wire in this phase.
|
|
14
14
|
Freshness = Data.define(
|
|
15
|
-
:stale, :
|
|
15
|
+
:stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
|
|
16
16
|
) do
|
|
17
|
-
def self.build(stale:,
|
|
17
|
+
def self.build(stale:, fetching: false, reason: nil, fetch_error: nil,
|
|
18
18
|
checked_at: nil, ttl_remaining_ms: nil)
|
|
19
19
|
new(
|
|
20
20
|
stale: stale,
|
|
21
|
-
|
|
21
|
+
fetching: fetching,
|
|
22
22
|
reason: reason,
|
|
23
|
-
|
|
23
|
+
fetch_error: fetch_error,
|
|
24
24
|
checked_at: checked_at,
|
|
25
25
|
ttl_remaining_ms: ttl_remaining_ms,
|
|
26
26
|
)
|
|
@@ -30,9 +30,9 @@ module Textus
|
|
|
30
30
|
h = {
|
|
31
31
|
"stale" => stale,
|
|
32
32
|
"stale_reason" => reason,
|
|
33
|
-
"
|
|
33
|
+
"fetching" => fetching,
|
|
34
34
|
}
|
|
35
|
-
h["
|
|
35
|
+
h["fetch_error"] = fetch_error unless fetch_error.nil?
|
|
36
36
|
h
|
|
37
37
|
end
|
|
38
38
|
end
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Domain
|
|
3
|
-
Permission = Data.define(:zone, :
|
|
4
|
-
def allows_write?(role)
|
|
5
|
-
write_policy.include?(role.to_s)
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
def allows_read?(role)
|
|
9
|
-
return true if [:all, ["all"]].include?(read_policy)
|
|
10
|
-
|
|
11
|
-
read_policy.include?(role.to_s)
|
|
12
|
-
end
|
|
3
|
+
Permission = Data.define(:zone, :writers) do
|
|
4
|
+
def allows_write?(role) = writers.include?(role.to_s)
|
|
13
5
|
end
|
|
14
6
|
end
|
|
15
7
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Policy
|
|
6
|
+
# The CLOSED floor (ADR 0031 §4): predicate names every transition
|
|
7
|
+
# evaluates regardless of rules:. rules[].guard only ADDS to these.
|
|
8
|
+
module BaseGuards
|
|
9
|
+
# The minimal floor — only what the verb is meaningless without.
|
|
10
|
+
# schema_valid / etag_match / fresh_within are NOT here: they are
|
|
11
|
+
# composable-only, added per-key via rules[].guard (ADR 0031).
|
|
12
|
+
BASE = {
|
|
13
|
+
put: %w[zone_writable_by],
|
|
14
|
+
delete: %w[zone_writable_by],
|
|
15
|
+
mv: %w[zone_writable_by],
|
|
16
|
+
accept: %w[author_held target_is_canon],
|
|
17
|
+
reject: %w[author_held],
|
|
18
|
+
fetch: %w[zone_writable_by],
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.for(transition) = BASE.fetch(transition, [])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Policy
|
|
6
|
+
# Immutable context handed to every predicate. `manifest` is the
|
|
7
|
+
# manifest (pure, no I/O); `envelope` is the entry under evaluation
|
|
8
|
+
# (nil when no bytes exist yet, e.g. a fresh put). `origin`/`target`
|
|
9
|
+
# are dotted keys; `transition` is the verb symbol.
|
|
10
|
+
Evaluation = Data.define(
|
|
11
|
+
:actor, :transition, :origin, :target, :envelope, :manifest
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Policy
|
|
6
|
+
# An ordered list of pure predicates over one Evaluation (ADR 0031).
|
|
7
|
+
# check! short-circuits on the first failing predicate that defines a
|
|
8
|
+
# bespoke #error (only zone_writable_by → WriteForbidden, the product's
|
|
9
|
+
# legible topology refusal); every other failure accumulates into
|
|
10
|
+
# GuardFailed naming the unmet predicate(s).
|
|
11
|
+
class Guard
|
|
12
|
+
attr_reader :predicates
|
|
13
|
+
|
|
14
|
+
def initialize(predicates)
|
|
15
|
+
@predicates = predicates
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def check!(eval)
|
|
19
|
+
accumulated = []
|
|
20
|
+
@predicates.each do |pred|
|
|
21
|
+
next if pred.call(eval)
|
|
22
|
+
raise pred.error(eval) if pred.respond_to?(:error)
|
|
23
|
+
|
|
24
|
+
accumulated << [pred.name, pred.reason]
|
|
25
|
+
end
|
|
26
|
+
raise Textus::GuardFailed.new(accumulated) unless accumulated.empty?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def explain(eval)
|
|
30
|
+
@predicates.map { |p| [p.name, p.call(eval), p.reason] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Policy
|
|
6
|
+
# Builds the effective Guard for (transition, key): base floor ++
|
|
7
|
+
# the predicates declared under rules[].guard[transition]. The single
|
|
8
|
+
# place the closed floor and the open ceiling are composed.
|
|
9
|
+
class GuardFactory
|
|
10
|
+
def initialize(manifest:, schemas:, extra: {})
|
|
11
|
+
@manifest = manifest
|
|
12
|
+
@schemas = schemas
|
|
13
|
+
@extra = extra # transient per-call params, e.g. { if_etag: "..." }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def for(transition, key)
|
|
17
|
+
specs = BaseGuards.for(transition) + composed(transition, key)
|
|
18
|
+
predicates = specs.map { |spec| build(spec) }.uniq(&:name)
|
|
19
|
+
Guard.new(predicates)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def composed(transition, key)
|
|
25
|
+
guard_map = @manifest.rules.for(key).guard
|
|
26
|
+
return [] if guard_map.nil?
|
|
27
|
+
|
|
28
|
+
Array(guard_map[transition.to_s])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build(spec)
|
|
32
|
+
# etag_match takes a per-call param rather than a manifest one.
|
|
33
|
+
return Predicates::EtagMatch.new(if_etag: @extra[:if_etag]) if spec == "etag_match"
|
|
34
|
+
|
|
35
|
+
Predicates::Registry.build(spec, schemas: @schemas)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
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: the acting role must hold the 'author' capability in the
|
|
8
|
+
# active manifest (ADR 0030 capability roles). Folds in the old
|
|
9
|
+
# Write::AuthorityGate so accept/reject and rules[].guard share one
|
|
10
|
+
# implementation. No bespoke #error — failures accumulate into
|
|
11
|
+
# GuardFailed (ADR 0031).
|
|
12
|
+
class AuthorHeld
|
|
13
|
+
attr_reader :reason
|
|
14
|
+
|
|
15
|
+
def name = "author_held"
|
|
16
|
+
|
|
17
|
+
def call(eval)
|
|
18
|
+
holders = eval.manifest.policy.roles_with_capability("author")
|
|
19
|
+
return true if holders.include?(eval.actor.to_s)
|
|
20
|
+
|
|
21
|
+
@reason =
|
|
22
|
+
if holders.empty?
|
|
23
|
+
"no role holds the 'author' capability; #{eval.transition} is disabled"
|
|
24
|
+
else
|
|
25
|
+
"role '#{eval.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
|
|
26
|
+
end
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Policy
|
|
6
|
+
module Predicates
|
|
7
|
+
# Advisory pre-flight etag check for policy explain. The
|
|
8
|
+
# authoritative compare-and-write stays in Envelope::IO::Writer
|
|
9
|
+
# (atomic write-then-audit, ADR 0017). Passes when no if_etag is
|
|
10
|
+
# supplied (params[:if_etag] nil) — guard does not require it.
|
|
11
|
+
class EtagMatch
|
|
12
|
+
attr_reader :reason
|
|
13
|
+
|
|
14
|
+
def initialize(if_etag: nil)
|
|
15
|
+
@if_etag = if_etag
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name = "etag_match"
|
|
19
|
+
|
|
20
|
+
def call(eval)
|
|
21
|
+
return true if @if_etag.nil?
|
|
22
|
+
return true if eval.envelope.nil? # creating; Writer handles race
|
|
23
|
+
return true if eval.envelope.etag == @if_etag
|
|
24
|
+
|
|
25
|
+
@reason = "etag mismatch: wanted #{@if_etag}, have #{eval.envelope.etag}"
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|