textus 0.55.1 → 0.55.2
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/CHANGELOG.md +1 -1
- data/README.md +9 -9
- data/SPEC.md +14 -13
- data/docs/architecture/README.md +3 -3
- data/docs/reference/conventions.md +5 -2
- data/lib/textus/boot.rb +64 -85
- data/lib/textus/{gate → dispatch}/binder.rb +8 -10
- data/lib/textus/dispatch/contracts.rb +63 -0
- data/lib/textus/dispatch/handler_registry.rb +21 -0
- data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
- data/lib/textus/dispatch/middleware/auth.rb +40 -0
- data/lib/textus/dispatch/middleware/base.rb +26 -0
- data/lib/textus/dispatch/middleware/binder.rb +20 -0
- data/lib/textus/dispatch/middleware/cascade.rb +53 -0
- data/lib/textus/dispatch/pipeline.rb +35 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/doctor/check.rb +8 -6
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +2 -0
- data/lib/textus/format/base.rb +36 -8
- data/lib/textus/format/json.rb +0 -21
- data/lib/textus/format/markdown.rb +0 -21
- data/lib/textus/format/yaml.rb +0 -21
- data/lib/textus/format.rb +16 -1
- data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
- data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
- data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
- data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
- data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
- data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
- data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
- data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
- data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
- data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
- data/lib/textus/handlers/read/audit_entries.rb +48 -0
- data/lib/textus/handlers/read/blame_entry.rb +71 -0
- data/lib/textus/handlers/read/deps_entry.rb +17 -0
- data/lib/textus/handlers/read/get_entry.rb +68 -0
- data/lib/textus/handlers/read/list_keys.rb +36 -0
- data/lib/textus/handlers/read/pulse_entries.rb +66 -0
- data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
- data/lib/textus/handlers/read/uid_entry.rb +18 -0
- data/lib/textus/handlers/read/where_entry.rb +18 -0
- data/lib/textus/handlers/write/accept_proposal.rb +39 -0
- data/lib/textus/handlers/write/data_mv.rb +55 -0
- data/lib/textus/handlers/write/delete_key.rb +17 -0
- data/lib/textus/handlers/write/enqueue_job.rb +27 -0
- data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
- data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
- data/lib/textus/handlers/write/move_key.rb +80 -0
- data/lib/textus/handlers/write/propose_entry.rb +29 -0
- data/lib/textus/handlers/write/put_entry.rb +29 -0
- data/lib/textus/handlers/write/reject_proposal.rb +29 -0
- data/lib/textus/init.rb +5 -5
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
- data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
- data/lib/textus/manifest/policy/predicates.rb +54 -0
- data/lib/textus/manifest/policy/retention.rb +1 -1
- data/lib/textus/orchestration.rb +55 -0
- data/lib/textus/port/audit_log.rb +6 -6
- data/lib/textus/port/build_lock.rb +1 -1
- data/lib/textus/{core → port}/sentinel.rb +1 -6
- data/lib/textus/port/sentinel_store.rb +3 -3
- data/lib/textus/port/storage/file_store.rb +23 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +58 -2
- data/lib/textus/port/watcher_lock.rb +2 -2
- data/lib/textus/produce/engine.rb +1 -11
- data/lib/textus/produce/publisher.rb +21 -0
- data/lib/textus/schema/registry.rb +42 -0
- data/lib/textus/schema/tools.rb +3 -10
- data/lib/textus/store/container.rb +140 -10
- data/lib/textus/store/cursor.rb +1 -1
- data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
- data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
- data/lib/textus/store/envelope/meta.rb +61 -0
- data/lib/textus/store/freshness/drift_detector.rb +93 -0
- data/lib/textus/store/freshness/evaluator.rb +20 -0
- data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
- data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
- data/lib/textus/store/freshness.rb +8 -0
- data/lib/textus/store/index/builder.rb +5 -3
- data/lib/textus/store/jobs/planner.rb +27 -7
- data/lib/textus/store/jobs/queue.rb +9 -1
- data/lib/textus/store/jobs/retention/base.rb +52 -0
- data/lib/textus/store/jobs/retention/sweep.rb +55 -0
- data/lib/textus/store/jobs/retention.rb +1 -43
- data/lib/textus/store/jobs/sweep.rb +2 -2
- data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
- data/lib/textus/store.rb +53 -30
- data/lib/textus/surface/cli/runner.rb +8 -9
- data/lib/textus/surface/cli/verb/doctor.rb +3 -2
- data/lib/textus/surface/cli/verb/get.rb +5 -3
- data/lib/textus/surface/cli/verb/put.rb +5 -3
- data/lib/textus/surface/mcp/catalog.rb +26 -62
- data/lib/textus/surface/mcp/errors.rb +0 -10
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/surface/mcp/server.rb +20 -31
- data/lib/textus/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +5 -4
- data/lib/textus/value/etag.rb +1 -1
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +36 -16
- data/lib/textus/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +1 -1
- data/lib/textus/workflow/runner.rb +10 -18
- data/lib/textus.rb +0 -64
- metadata +70 -70
- data/lib/textus/action/accept.rb +0 -46
- data/lib/textus/action/audit.rb +0 -94
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -79
- data/lib/textus/action/boot.rb +0 -15
- data/lib/textus/action/data_mv.rb +0 -58
- data/lib/textus/action/deps.rb +0 -19
- data/lib/textus/action/doctor.rb +0 -17
- data/lib/textus/action/drain.rb +0 -31
- data/lib/textus/action/enqueue.rb +0 -37
- data/lib/textus/action/get.rb +0 -34
- data/lib/textus/action/ingest.rb +0 -199
- data/lib/textus/action/jobs.rb +0 -27
- data/lib/textus/action/key_delete.rb +0 -26
- data/lib/textus/action/key_delete_prefix.rb +0 -35
- data/lib/textus/action/key_mv.rb +0 -122
- data/lib/textus/action/key_mv_prefix.rb +0 -48
- data/lib/textus/action/list.rb +0 -28
- data/lib/textus/action/propose.rb +0 -42
- data/lib/textus/action/published.rb +0 -22
- data/lib/textus/action/pulse.rb +0 -49
- data/lib/textus/action/put.rb +0 -38
- data/lib/textus/action/rdeps.rb +0 -24
- data/lib/textus/action/reject.rb +0 -28
- data/lib/textus/action/rule_explain.rb +0 -81
- data/lib/textus/action/rule_lint.rb +0 -62
- data/lib/textus/action/rule_list.rb +0 -38
- data/lib/textus/action/schema_envelope.rb +0 -22
- data/lib/textus/action/uid.rb +0 -19
- data/lib/textus/action/where.rb +0 -21
- data/lib/textus/contract/arg.rb +0 -10
- data/lib/textus/contract/dsl.rb +0 -88
- data/lib/textus/contract/spec.rb +0 -25
- data/lib/textus/contract.rb +0 -12
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- data/lib/textus/format/shared.rb +0 -17
- data/lib/textus/gate/auth.rb +0 -212
- data/lib/textus/gate.rb +0 -92
- data/lib/textus/meta.rb +0 -54
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/store/compositor.rb +0 -34
- data/lib/textus/store/session.rb +0 -37
- data/lib/textus/surface/projector.rb +0 -27
- data/lib/textus/surface/role_scope.rb +0 -34
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Core
|
|
5
|
-
module Retention
|
|
6
|
-
# Retention sweep reporter (ADR 0093/0099). Which entries are past their
|
|
7
|
-
# `retention:` ttl and the destructive action that applies. Age basis: file
|
|
8
|
-
# mtime. Only drop/archive. Renamed off the Core::Retention vs
|
|
9
|
-
# Manifest::Policy::Retention collision (ADR 0099).
|
|
10
|
-
class Sweep
|
|
11
|
-
def self.expired?(ttl_seconds:, mtime:, now:)
|
|
12
|
-
return false if ttl_seconds.nil? || mtime.nil?
|
|
13
|
-
|
|
14
|
-
(now - mtime).to_i > ttl_seconds
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def initialize(manifest:, file_stat:, clock:)
|
|
18
|
-
@manifest = manifest
|
|
19
|
-
@file_stat = file_stat
|
|
20
|
-
@clock = clock
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def call(prefix: nil, lane: nil)
|
|
24
|
-
@manifest.data.entries
|
|
25
|
-
.select { |m| matches?(m, prefix: prefix, lane: lane) }
|
|
26
|
-
.flat_map { |m| rows_for(m) }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
def matches?(mentry, prefix:, lane:)
|
|
32
|
-
return false if lane && mentry.lane != lane
|
|
33
|
-
return false if prefix && !Textus::Key::Matching.matches_prefix?(
|
|
34
|
-
mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
true
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def rows_for(mentry)
|
|
41
|
-
policy = @manifest.rules.for(mentry.key).retention
|
|
42
|
-
return [] if policy.nil?
|
|
43
|
-
|
|
44
|
-
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
45
|
-
path = row[:path]
|
|
46
|
-
next unless @file_stat.exists?(path)
|
|
47
|
-
next unless self.class.expired?(
|
|
48
|
-
ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
{ "key" => row[:key], "path" => path, "action" => policy.action.to_s }
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Core
|
|
5
|
-
# Retention — "is the entry old enough to retire?" (Q2, ADR 0093/0099).
|
|
6
|
-
# GC dueness, orthogonal to Freshness (content currency). The reporter is
|
|
7
|
-
# Core::Retention::Sweep; the manifest rule policy is Manifest::Policy::Retention.
|
|
8
|
-
module Retention
|
|
9
|
-
end
|
|
10
|
-
end
|
|
11
|
-
end
|
data/lib/textus/format/shared.rb
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Format
|
|
3
|
-
module Shared
|
|
4
|
-
ENFORCE_NAME_RE = /\.(md|json|yaml|yml|txt)\z/i
|
|
5
|
-
|
|
6
|
-
def self.enforce_name_match!(path, meta, extensions)
|
|
7
|
-
return unless meta.is_a?(Hash) && meta["name"]
|
|
8
|
-
|
|
9
|
-
ext = extensions.first
|
|
10
|
-
basename = File.basename(path, ext)
|
|
11
|
-
return if meta["name"] == basename
|
|
12
|
-
|
|
13
|
-
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
data/lib/textus/gate/auth.rb
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Gate
|
|
5
|
-
class Auth
|
|
6
|
-
FLOOR = {
|
|
7
|
-
put: %w[lane_writable_by raw_lane_ingest_only],
|
|
8
|
-
key_delete: %w[lane_deletable_by],
|
|
9
|
-
key_mv: %w[lane_writable_by raw_lane_ingest_only],
|
|
10
|
-
accept: %w[author_held],
|
|
11
|
-
reject: %w[author_held],
|
|
12
|
-
propose: %w[lane_writable_by raw_lane_ingest_only],
|
|
13
|
-
key_mv_prefix: %w[lane_writable_by raw_lane_ingest_only],
|
|
14
|
-
key_delete_prefix: %w[lane_writable_by raw_lane_ingest_only],
|
|
15
|
-
ingest: %w[lane_writable_by raw_write_once],
|
|
16
|
-
}.freeze
|
|
17
|
-
|
|
18
|
-
AuthContext = Struct.new(
|
|
19
|
-
:actor, :actor_caps, :lane_verb,
|
|
20
|
-
:action, :target, :envelope,
|
|
21
|
-
:mentry, :manifest,
|
|
22
|
-
keyword_init: true
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
def initialize(container)
|
|
26
|
-
@manifest = container.manifest
|
|
27
|
-
@schemas = container.schemas
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def check!(cmd)
|
|
31
|
-
key = extract_key(cmd)
|
|
32
|
-
return unless key
|
|
33
|
-
|
|
34
|
-
evaluate_predicates(
|
|
35
|
-
action: cmd.verb,
|
|
36
|
-
actor: cmd.role.to_s,
|
|
37
|
-
key: key,
|
|
38
|
-
envelope: nil,
|
|
39
|
-
extra: {},
|
|
40
|
-
)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Backward-compatible check for inline action auth (accept, put, etc.).
|
|
44
|
-
def check_action!(action:, actor:, key:, envelope: nil, extra: {})
|
|
45
|
-
evaluate_predicates(
|
|
46
|
-
action: action.to_sym,
|
|
47
|
-
actor: actor,
|
|
48
|
-
key: key,
|
|
49
|
-
envelope: envelope,
|
|
50
|
-
extra: extra,
|
|
51
|
-
)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def evaluate_predicates(action:, actor:, key:, envelope:, extra:)
|
|
57
|
-
mentry = @manifest.resolver.resolve(key).entry
|
|
58
|
-
lane_verb = @manifest.policy.verb_for_lane(mentry.lane.to_s)
|
|
59
|
-
actor_caps = Set.new(@manifest.data.role_caps.fetch(actor, []))
|
|
60
|
-
|
|
61
|
-
ctx = AuthContext.new(
|
|
62
|
-
actor:, actor_caps:, lane_verb:,
|
|
63
|
-
action:, target: key, envelope:,
|
|
64
|
-
mentry:, manifest: @manifest
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
failures = []
|
|
68
|
-
floor_preds = FLOOR.fetch(action, [])
|
|
69
|
-
rule_preds = rule_declared_predicates(action, key)
|
|
70
|
-
(floor_preds + rule_preds).uniq.each do |pred|
|
|
71
|
-
result = evaluate(pred, ctx, extra)
|
|
72
|
-
next if result[:pass]
|
|
73
|
-
raise result[:error] if result[:error]
|
|
74
|
-
|
|
75
|
-
failures << [pred, result[:reason]]
|
|
76
|
-
end
|
|
77
|
-
raise Textus::GuardFailed.new(failures) unless failures.empty?
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def extract_key(cmd)
|
|
81
|
-
cmd.params.key?(:pending_key) ? cmd.pending_key : cmd.key
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def rule_declared_predicates(action, key)
|
|
85
|
-
guard_map = @manifest.rules.for(key).guard
|
|
86
|
-
return [] if guard_map.nil?
|
|
87
|
-
|
|
88
|
-
Array(guard_map[action.to_s])
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def evaluate(pred_name, ctx, extra)
|
|
92
|
-
case pred_name
|
|
93
|
-
when "lane_writable_by" then evaluate_lane_writable_by(ctx)
|
|
94
|
-
when "author_held" then evaluate_author_held(ctx)
|
|
95
|
-
when "target_is_canon" then evaluate_target_is_canon(ctx)
|
|
96
|
-
when "etag_match" then evaluate_etag_match(ctx, extra)
|
|
97
|
-
when "schema_valid" then evaluate_schema_valid(ctx)
|
|
98
|
-
when "fresh_within" then { pass: true }
|
|
99
|
-
when "raw_lane_ingest_only" then evaluate_raw_lane_ingest_only(ctx)
|
|
100
|
-
when "raw_write_once" then evaluate_raw_write_once(ctx)
|
|
101
|
-
when "lane_deletable_by" then evaluate_lane_deletable_by(ctx)
|
|
102
|
-
else raise Textus::UsageError.new("unknown predicate '#{pred_name}'")
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def evaluate_lane_writable_by(ctx)
|
|
107
|
-
pass = ctx.actor_caps.include?(ctx.lane_verb.to_s)
|
|
108
|
-
return { pass: true } if pass
|
|
109
|
-
|
|
110
|
-
holders = @manifest.policy.roles_with_capability(ctx.lane_verb.to_s)
|
|
111
|
-
{ pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane, verb: ctx.lane_verb, holders:) }
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def evaluate_author_held(ctx)
|
|
115
|
-
holders = @manifest.policy.roles_with_capability("author")
|
|
116
|
-
pass = holders.include?(ctx.actor.to_s)
|
|
117
|
-
reason = if pass
|
|
118
|
-
nil
|
|
119
|
-
elsif holders.empty?
|
|
120
|
-
"no role holds the 'author' capability; #{ctx.action} is disabled"
|
|
121
|
-
else
|
|
122
|
-
"role '#{ctx.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
|
|
123
|
-
end
|
|
124
|
-
{ pass:, reason: }
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def evaluate_target_is_canon(ctx)
|
|
128
|
-
kind = @manifest.policy.declared_kind(ctx.mentry.lane.to_s)
|
|
129
|
-
pass = kind == :canon
|
|
130
|
-
{ pass:, reason: pass ? nil : "target lane '#{ctx.mentry.lane}' is not canon (kind: #{kind})" }
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def evaluate_etag_match(ctx, extra)
|
|
134
|
-
if_etag = extra[:if_etag]
|
|
135
|
-
return { pass: true } if if_etag.nil?
|
|
136
|
-
|
|
137
|
-
current = ctx.envelope&.etag
|
|
138
|
-
pass = current.nil? || current == if_etag
|
|
139
|
-
{ pass:, error: pass ? nil : Textus::EtagMismatch.new(ctx.target, if_etag, current) }
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def evaluate_schema_valid(ctx)
|
|
143
|
-
return { pass: true } unless ctx.envelope
|
|
144
|
-
|
|
145
|
-
schema_ref = ctx.mentry.schema
|
|
146
|
-
return { pass: true } unless schema_ref
|
|
147
|
-
|
|
148
|
-
schema = @schemas.fetch_or_nil(schema_ref)
|
|
149
|
-
return { pass: true } unless schema
|
|
150
|
-
|
|
151
|
-
frontmatter = ctx.envelope.meta&.dig("_meta") || ctx.envelope.meta || {}
|
|
152
|
-
begin
|
|
153
|
-
schema.validate!(frontmatter)
|
|
154
|
-
{ pass: true }
|
|
155
|
-
rescue Textus::SchemaViolation => e
|
|
156
|
-
{ pass: false, reason: schema_reason(e) }
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def evaluate_raw_lane_ingest_only(ctx)
|
|
161
|
-
return { pass: true } unless @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
|
|
162
|
-
return { pass: true } if ctx.action == :ingest
|
|
163
|
-
|
|
164
|
-
{ pass: false, error: Textus::Error.new(
|
|
165
|
-
:raw_lane_ingest_only,
|
|
166
|
-
"raw lane '#{ctx.mentry.lane}' only accepts `textus ingest` — " \
|
|
167
|
-
"use that verb instead of '#{ctx.action}'",
|
|
168
|
-
) }
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def evaluate_raw_write_once(ctx)
|
|
172
|
-
path = @manifest.resolver.resolve(ctx.target).path
|
|
173
|
-
return { pass: true } unless File.exist?(path)
|
|
174
|
-
|
|
175
|
-
{ pass: false, error: Textus::Error.new(
|
|
176
|
-
:raw_write_once,
|
|
177
|
-
"raw entry '#{ctx.target}' already exists; " \
|
|
178
|
-
"delete it first (`textus key-delete #{ctx.target}`), then re-ingest",
|
|
179
|
-
) }
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# Deletion authority: the lane's write capability OR the author capability.
|
|
183
|
-
# On raw-kind lanes only the author capability grants deletion (correction
|
|
184
|
-
# escape hatch); the lane's own verb (ingest) is write-only. On all other
|
|
185
|
-
# lane kinds the behaviour matches lane_writable_by — the lane's writer
|
|
186
|
-
# can delete as before.
|
|
187
|
-
def evaluate_lane_deletable_by(ctx)
|
|
188
|
-
is_raw = @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
|
|
189
|
-
pass = if is_raw
|
|
190
|
-
ctx.actor_caps.include?("author")
|
|
191
|
-
else
|
|
192
|
-
ctx.actor_caps.include?(ctx.lane_verb.to_s) || ctx.actor_caps.include?("author")
|
|
193
|
-
end
|
|
194
|
-
return { pass: true } if pass
|
|
195
|
-
|
|
196
|
-
extra_holders = is_raw ? ["author"] : [ctx.lane_verb.to_s, "author"]
|
|
197
|
-
holders = extra_holders.flat_map { |v| @manifest.policy.roles_with_capability(v) }.uniq
|
|
198
|
-
{ pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane,
|
|
199
|
-
verb: ctx.lane_verb, holders:) }
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def schema_reason(err)
|
|
203
|
-
d = err.details
|
|
204
|
-
return err.message.dup unless d.is_a?(Hash)
|
|
205
|
-
return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
|
|
206
|
-
return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
|
|
207
|
-
|
|
208
|
-
err.message.dup
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
end
|
data/lib/textus/gate.rb
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Gate
|
|
5
|
-
def initialize(container)
|
|
6
|
-
@container = container
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def dispatch(spec:, inputs:, role:, correlation_id: nil, session: nil, surface: nil)
|
|
10
|
-
resolved = Binder.bind(spec, inputs, session: session)
|
|
11
|
-
cmd = Value::Command.new(verb: spec.verb, params: resolved.freeze, role: role)
|
|
12
|
-
|
|
13
|
-
cmd = normalize_propose_key(cmd) if cmd.verb == :propose
|
|
14
|
-
action_class = Textus::Action::VERBS.fetch(cmd.verb) do
|
|
15
|
-
raise Textus::UsageError.new("unknown command verb: #{cmd.verb}")
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
auth = Gate::Auth.new(@container)
|
|
19
|
-
auth.check!(cmd)
|
|
20
|
-
check_dispatch_auth(cmd, resolved, auth)
|
|
21
|
-
call_obj = build_call(cmd, correlation_id: correlation_id)
|
|
22
|
-
result = run_action(action_class, resolved, call_obj)
|
|
23
|
-
cascade(cmd, result, call_obj) if CASCADE_VERBS.include?(cmd.verb) && !call_obj.dry_run
|
|
24
|
-
return result unless surface
|
|
25
|
-
|
|
26
|
-
spec.view(surface).call(result, resolved)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
CASCADE_VERBS = %i[put propose accept reject key_mv key_delete].freeze
|
|
30
|
-
|
|
31
|
-
AUTH_KEYS = {
|
|
32
|
-
key_mv: ->(params) { [params[:old_key], params[:new_key]] },
|
|
33
|
-
ingest: ->(params) { Textus::Action::Ingest.dispatch_key(**params) },
|
|
34
|
-
}.freeze
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def check_dispatch_auth(cmd, resolved, auth)
|
|
39
|
-
return unless (resolver = AUTH_KEYS[cmd.verb])
|
|
40
|
-
|
|
41
|
-
keys = Array(resolver.call(resolved))
|
|
42
|
-
keys.each { |k| auth.check_action!(action: cmd.verb, actor: cmd.role, key: k) }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def normalize_propose_key(cmd)
|
|
46
|
-
return cmd if cmd.pending_key
|
|
47
|
-
|
|
48
|
-
zone = @container.manifest.policy.propose_lane_for(cmd.role.to_s)
|
|
49
|
-
key = zone ? "#{zone}.#{cmd.key}" : nil
|
|
50
|
-
cmd.with(params: cmd.params.merge(pending_key: key))
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def run_action(klass, params, call_obj)
|
|
54
|
-
result = klass.call(container: @container, call: call_obj, **params)
|
|
55
|
-
Value::Result.unwrap(result)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def build_call(cmd, correlation_id: nil)
|
|
59
|
-
dry_run = cmd.params.key?(:dry_run) ? !cmd.params[:dry_run].nil? : false
|
|
60
|
-
Textus::Value::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def cascade(cmd, result, call)
|
|
64
|
-
key = result.is_a?(Hash) ? result["cascade_key"] : nil
|
|
65
|
-
key ||= cascade_key_from_params(cmd)
|
|
66
|
-
return unless key
|
|
67
|
-
|
|
68
|
-
rdeps_result = Textus::Action::Rdeps.call(container: @container, call: call, key: key)
|
|
69
|
-
rdeps = Value::Result.unwrap(rdeps_result).fetch("rdeps", [])
|
|
70
|
-
producible = rdeps.select { |dep_key| producible?(dep_key) }
|
|
71
|
-
producible.each do |dep_key|
|
|
72
|
-
Textus::Store::Jobs::Materialize.call(container: @container, call: call, key: dep_key)
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def cascade_key_from_params(cmd)
|
|
77
|
-
case cmd.verb
|
|
78
|
-
when :put, :key_delete then cmd.params[:key]
|
|
79
|
-
when :key_mv then cmd.params[:new_key]
|
|
80
|
-
when :propose, :reject then cmd.params[:pending_key]
|
|
81
|
-
when :accept then nil
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def producible?(key)
|
|
86
|
-
entry = @container.manifest.resolver.resolve(key).entry
|
|
87
|
-
!entry.publish_tree.nil?
|
|
88
|
-
rescue Textus::Error
|
|
89
|
-
false
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
data/lib/textus/meta.rb
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Meta
|
|
7
|
-
NO_META_FORMATS = %w[text].freeze
|
|
8
|
-
|
|
9
|
-
FIELDS = {
|
|
10
|
-
"uid" => {
|
|
11
|
-
inject: lambda { |meta, content, existing_meta|
|
|
12
|
-
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
13
|
-
existing = existing_meta.is_a?(Hash) ? existing_meta["uid"] : nil
|
|
14
|
-
m["uid"] = existing || Textus::Value::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
15
|
-
[m, content]
|
|
16
|
-
},
|
|
17
|
-
},
|
|
18
|
-
"sources" => {
|
|
19
|
-
inject: lambda { |meta, content, existing_meta|
|
|
20
|
-
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
21
|
-
existing = existing_meta.is_a?(Hash) ? existing_meta["sources"] : nil
|
|
22
|
-
|
|
23
|
-
if m.key?("sources")
|
|
24
|
-
raise Textus::BadContent.new(nil, "_meta.sources must be an array") unless m["sources"].is_a?(Array)
|
|
25
|
-
|
|
26
|
-
m["sources"] = m["sources"].map { |s| validate_source_shape!(s) }
|
|
27
|
-
elsif existing.is_a?(Array) && !existing.empty?
|
|
28
|
-
m["sources"] = existing
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
[m, content]
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
}.freeze
|
|
35
|
-
|
|
36
|
-
def self.inject_all(meta, content, existing_meta = {}, format: nil)
|
|
37
|
-
return [meta, content] if NO_META_FORMATS.include?(format)
|
|
38
|
-
|
|
39
|
-
FIELDS.each_value do |field|
|
|
40
|
-
meta, content = field[:inject].call(meta, content, existing_meta)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
[meta, content]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def self.validate_source_shape!(src)
|
|
47
|
-
raise Textus::BadContent.new(nil, "each source must be a string") unless src.is_a?(String)
|
|
48
|
-
|
|
49
|
-
raise Textus::BadContent.new(nil, "each source must start with 'raw.', got #{src.inspect}") unless src.match?(/\Araw\./)
|
|
50
|
-
|
|
51
|
-
src
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
data/lib/textus/schemas.rb
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
# Eager-loading schema cache. Loads every *.yaml under +dir+ at construction.
|
|
3
|
-
# A missing directory is treated as "no schemas" (does not raise) to mirror
|
|
4
|
-
# the lazy behavior previously embedded in Store#schema_for.
|
|
5
|
-
class Schemas
|
|
6
|
-
def initialize(dir)
|
|
7
|
-
@dir = dir
|
|
8
|
-
@schemas = {}
|
|
9
|
-
load_all
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def fetch(name)
|
|
13
|
-
@schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Only nil short-circuits. A missing-but-named schema still raises IoError.
|
|
17
|
-
def fetch_or_nil(name)
|
|
18
|
-
return nil if name.nil?
|
|
19
|
-
|
|
20
|
-
fetch(name)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def all
|
|
24
|
-
@schemas.values
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Name-keyed view: { canonical_name => Schema }. The key is the schema's
|
|
28
|
-
# file stem, which is authoritative even when a schema file carries no
|
|
29
|
-
# top-level `name:` (Schema#name reads the body and may be nil). Symmetric
|
|
30
|
-
# with #all (values); use this when you need the names too.
|
|
31
|
-
def by_name
|
|
32
|
-
@schemas.dup
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
def load_all
|
|
38
|
-
return unless File.directory?(@dir)
|
|
39
|
-
|
|
40
|
-
Dir.glob(File.join(@dir, "*.yaml")).each do |path|
|
|
41
|
-
name = File.basename(path, ".yaml")
|
|
42
|
-
begin
|
|
43
|
-
@schemas[name] = Schema.load(path)
|
|
44
|
-
rescue StandardError
|
|
45
|
-
# Tolerate broken schema files at construction time so the rest of
|
|
46
|
-
# the store remains loadable. Surfacing the failure is the job of
|
|
47
|
-
# Doctor::Check::SchemaParseError. Lookups via #fetch still raise
|
|
48
|
-
# IoError for the missing-but-named schema.
|
|
49
|
-
next
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Store
|
|
5
|
-
class Compositor
|
|
6
|
-
def initialize(container)
|
|
7
|
-
@container = container
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def write(key, mentry:, payload:, call:, if_etag: nil)
|
|
11
|
-
Textus::Store::Envelope::Writer.from(container: @container, call: call)
|
|
12
|
-
.put(key, mentry: mentry, payload: payload, if_etag: if_etag)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def read(key)
|
|
16
|
-
Textus::Store::Envelope::Reader.from(container: @container).read(key)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def delete(key, call:, mentry: nil, if_etag: nil)
|
|
20
|
-
Textus::Store::Envelope::Writer.from(container: @container, call: call)
|
|
21
|
-
.delete(key, mentry: mentry, if_etag: if_etag)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def move(from_key:, to_key:, new_mentry:, call:, if_etag: nil)
|
|
25
|
-
Textus::Store::Envelope::Writer.from(container: @container, call: call)
|
|
26
|
-
.move(from_key: from_key, to_key: to_key, new_mentry: new_mentry, if_etag: if_etag)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def exists?(key)
|
|
30
|
-
Textus::Store::Envelope::Reader.from(container: @container).exists?(key)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
data/lib/textus/store/session.rb
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry-struct"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
# The agent session: per-connection (MCP), per-process (CLI), or per-loop
|
|
7
|
-
# (Ruby) orientation state — the audit cursor plus the contract etag and
|
|
8
|
-
# propose_lane captured at boot. Immutable Dry::Struct::Value; advance_cursor
|
|
9
|
-
# and with return new instances. ADR 0036; contract_etag widened in ADR 0074.
|
|
10
|
-
class Store
|
|
11
|
-
class Session < Dry::Struct
|
|
12
|
-
attribute :role, Value::Types::RoleName
|
|
13
|
-
attribute :cursor, Value::Types::Cursor
|
|
14
|
-
attribute :propose_lane, Value::Types::String.optional
|
|
15
|
-
attribute :contract_etag, Value::Types::String
|
|
16
|
-
|
|
17
|
-
def with(**attrs) = self.class.new(to_h.merge(attrs))
|
|
18
|
-
|
|
19
|
-
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
20
|
-
|
|
21
|
-
def check_etag!(observed_etag)
|
|
22
|
-
return if observed_etag == contract_etag
|
|
23
|
-
|
|
24
|
-
raise Textus::ContractDrift.new(
|
|
25
|
-
"contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
|
|
26
|
-
"now #{short_etag(observed_etag)}); re-run boot",
|
|
27
|
-
)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
private
|
|
31
|
-
|
|
32
|
-
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
33
|
-
# the drift diagnostic.
|
|
34
|
-
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Surface
|
|
3
|
-
class Projector
|
|
4
|
-
def initialize(view_key: :default, binder_method: :inputs_from_wire)
|
|
5
|
-
@view_key = view_key
|
|
6
|
-
@binder_method = binder_method
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def verbs(action_verbs = Textus::Action::VERBS)
|
|
10
|
-
action_verbs.select do |_verb, klass|
|
|
11
|
-
klass.respond_to?(:contract?) && klass.contract?
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def names(action_verbs = Textus::Action::VERBS)
|
|
16
|
-
verbs(action_verbs).keys.map(&:to_s)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def dispatch(verb_name, inputs:, store:, role:, session: nil)
|
|
20
|
-
klass = Textus::Action::VERBS.fetch(verb_name.to_sym)
|
|
21
|
-
spec = klass.contract
|
|
22
|
-
bound = Textus::Gate::Binder.public_send(@binder_method, spec, inputs)
|
|
23
|
-
store.gate.dispatch(spec:, inputs: bound, role:, session:, surface: @view_key)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Surface
|
|
5
|
-
# Role-scoped identity carrier. Holds the acting identity (role,
|
|
6
|
-
# correlation_id, dry_run) bound to a container. All verb methods
|
|
7
|
-
# (put, get, accept, ...) are injected by textus.rb's define_method
|
|
8
|
-
# loop, which dispatches directly through Gate.
|
|
9
|
-
class RoleScope
|
|
10
|
-
attr_reader :container, :role, :correlation_id
|
|
11
|
-
|
|
12
|
-
def initialize(container:, role:, dry_run: false, correlation_id: nil)
|
|
13
|
-
@container = container
|
|
14
|
-
@role = role.to_s
|
|
15
|
-
@dry_run = dry_run
|
|
16
|
-
@correlation_id = correlation_id || SecureRandom.uuid
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def dry_run? = !!@dry_run
|
|
20
|
-
|
|
21
|
-
def with_role(role)
|
|
22
|
-
self.class.new(container: @container, role:, dry_run: @dry_run, correlation_id: @correlation_id)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def with_correlation_id(cid)
|
|
26
|
-
self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def with_dry_run
|
|
30
|
-
self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|