textus 0.22.0 → 0.26.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 +148 -45
- data/CHANGELOG.md +102 -0
- data/README.md +1 -1
- data/SPEC.md +12 -12
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/boot.rb +27 -29
- data/lib/textus/builder/pipeline.rb +3 -3
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +2 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +1 -1
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +4 -4
- data/lib/textus/manifest/entry/derived.rb +4 -5
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +14 -14
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest.rb +53 -111
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +14 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +61 -36
- data/lib/textus/application/reads/audit.rb +0 -94
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/pulse.rb +0 -63
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -81
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/operations.rb +0 -176
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e822d94de7ae06481dd8c03eab813a097b62e78121d94c9d1045017675da665c
|
|
4
|
+
data.tar.gz: 3372f74bb31465b28be8e6ce5b4721a3f15cae7536ebdfc8ef9b6c6d7c9f2d88
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 31e4b83f9a7d3b061d043c447393b23ba404a47e7d79b0a411ad49719625d01ec30116a067f28bf88ac6944ff0262c1825d35c851022340d63b813dca26f98aa
|
|
7
|
+
data.tar.gz: 590cc3419cb7823688bab11c4fce49a011bf62b463ab887b415753dd3c8037cd0704b8ccc4df6e778b21c04bd8101847a28fa5d4819e447e7af3d44febc017f3
|
data/ARCHITECTURE.md
CHANGED
|
@@ -2,88 +2,191 @@
|
|
|
2
2
|
|
|
3
3
|
```
|
|
4
4
|
┌─ Interface ────────────────────────────────────────────────┐
|
|
5
|
-
│ CLI verbs:
|
|
6
|
-
│
|
|
7
|
-
│ # case
|
|
5
|
+
│ CLI verbs: session = Session.for(store, role:) │
|
|
6
|
+
│ session.<name>(...) # one method per │
|
|
7
|
+
│ # registered use case │
|
|
8
|
+
│ # (put/get/refresh/…) │
|
|
8
9
|
│ │
|
|
9
|
-
│
|
|
10
|
-
│ Operations.new(ctx:, manifest:, file_store:, │
|
|
11
|
-
│ schemas:, audit_log:, bus:, │
|
|
12
|
-
│ registry:, root:, store:) │
|
|
10
|
+
│ MCP gate: textus mcp serve — same use cases, JSON-RPC. │
|
|
13
11
|
└──────────────────────┬─────────────────────────────────────┘
|
|
14
12
|
│
|
|
15
13
|
┌─ Application ────────▼─────────────────────────────────────┐
|
|
16
14
|
│ Context (slim Data: role, correlation_id, now, │
|
|
17
15
|
│ dry_run — request state only) │
|
|
18
|
-
│
|
|
16
|
+
│ Caps (Read/Write/Hook records — store slices) │
|
|
17
|
+
│ Session (per-call dispatch; methods generated │
|
|
18
|
+
│ from UseCase registry) │
|
|
19
|
+
│ UseCase (registry: verb → module, caps_kind) │
|
|
19
20
|
│ │
|
|
20
|
-
│
|
|
21
|
-
│
|
|
22
|
-
│
|
|
23
|
-
│
|
|
24
|
-
│
|
|
25
|
-
│
|
|
26
|
-
│
|
|
21
|
+
│ read/{get,get_or_refresh,list,where,uid,schema_envelope, │
|
|
22
|
+
│ deps,rdeps,published,stale,validate_all, │
|
|
23
|
+
│ freshness,audit,blame,policy_explain,pulse}.rb │
|
|
24
|
+
│ write/{put,delete,mv,accept,reject,publish, │
|
|
25
|
+
│ materializer,authority_gate, │
|
|
26
|
+
│ refresh_worker,refresh_orchestrator,refresh_all} │
|
|
27
|
+
│ maintenance/{migrate,key_mv_prefix,key_delete_prefix, │
|
|
28
|
+
│ zone_mv,rule_lint}.rb │
|
|
29
|
+
│ envelope/{reader,writer}.rb (split: parse vs persist) │
|
|
30
|
+
│ projection.rb │
|
|
27
31
|
└──────────┬───────────────────────────────┬─────────────────┘
|
|
28
32
|
│ uses domain │ uses ports
|
|
29
33
|
┌─ Domain ─▼─────────────────────────────────────────────────┐
|
|
30
34
|
│ Authorizer (manifest + role → allow / deny) │
|
|
31
35
|
│ Permission (write/read predicate per zone) │
|
|
32
36
|
│ Freshness::{Policy,Verdict,Evaluator} │
|
|
33
|
-
│
|
|
34
|
-
│
|
|
37
|
+
│ Staleness (Generator/Intake checks) │
|
|
38
|
+
│ Action Outcome Sentinel │
|
|
39
|
+
│ Policy::{Promote,Refresh,Matcher,HandlerAllowlist, │
|
|
40
|
+
│ Predicates::{SchemaValid,AcceptAuthoritySigned}} │
|
|
35
41
|
└──────────────────────────────────────────┬─────────────────┘
|
|
36
42
|
│ implements
|
|
37
43
|
┌─ Infrastructure ─────────────────────────▼─────────────────┐
|
|
38
|
-
│ Store (composition root — wires ports
|
|
44
|
+
│ Store (composition root — wires ports, │
|
|
45
|
+
│ vends Sessions) │
|
|
39
46
|
│ Storage::FileStore (bytes-only port: read/write/delete/ │
|
|
40
47
|
│ exists?/etag) │
|
|
41
|
-
│ Manifest (
|
|
48
|
+
│ Manifest (Data, Resolver, Policy, Rules) │
|
|
42
49
|
│ Schemas (eager-load cache) │
|
|
43
|
-
│ AuditLog
|
|
44
|
-
│
|
|
45
|
-
│
|
|
46
|
-
│
|
|
50
|
+
│ Infra::{AuditLog,AuditSubscriber,Publisher,Clock, │
|
|
51
|
+
│ Refresh::Lock,Refresh::Detached,BuildLock} │
|
|
52
|
+
│ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport, │
|
|
53
|
+
│ Builtin,ErrorLog} │
|
|
47
54
|
│ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
|
|
48
55
|
└────────────────────────────────────────────────────────────┘
|
|
49
56
|
|
|
50
57
|
Dependency rule: arrows point DOWN. Domain has zero outbound
|
|
51
58
|
imports. Application imports Domain + Infra (via ports).
|
|
52
|
-
Use cases declare their real
|
|
59
|
+
Use cases declare their real collaborators in their Impl
|
|
60
|
+
constructor; UseCase.register hooks them into Session.
|
|
53
61
|
```
|
|
54
62
|
|
|
55
|
-
##
|
|
63
|
+
## How a verb becomes a method
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
Each application use case is a module under `lib/textus/application/{read,write,maintenance}/`. The shape is uniform:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
module Textus
|
|
69
|
+
module Application
|
|
70
|
+
module Read
|
|
71
|
+
module Get
|
|
72
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
73
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class Impl
|
|
77
|
+
def initialize(ctx:, caps:, ...)
|
|
78
|
+
@ctx = ctx; @manifest = caps.manifest; ...
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def call(key) ... end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`Session` generates one dispatch method per registered entry (see `lib/textus/session.rb` — the `Application::UseCase.each do |entry| ... end` block at the bottom). Adding a new verb is **one `UseCase.register` line** plus the module — no edits to `Session`.
|
|
92
|
+
|
|
93
|
+
Two collaborators live outside the registry because they're composed by other use cases, not invoked as verbs:
|
|
94
|
+
|
|
95
|
+
- `Application::Write::RefreshOrchestrator` — composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`. Session memoizes one (`session.refresh_orchestrator`).
|
|
96
|
+
- `Application::Envelope::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`. Session memoizes both.
|
|
97
|
+
|
|
98
|
+
## Caps
|
|
99
|
+
|
|
100
|
+
Use cases never see the raw `Store`. `Application::Caps` defines three role-scoped slices:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
ReadCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events)
|
|
104
|
+
WriteCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events, :authorizer)
|
|
105
|
+
HookCaps = Data.define(:events, :rpc, :manifest, :root)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`Session.for(store, role:)` builds all three via `Application.caps_from_store(store)`; the dispatch method picks `read_caps` or `write_caps` based on the `caps_kind` declared at registration time. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive a `caps:` kwarg that is the appropriate Read/Write slice — legacy `store:` is rejected by `Hooks::RpcRegistry#invoke`.
|
|
109
|
+
|
|
110
|
+
## Read path (`session.get(key)`)
|
|
111
|
+
|
|
112
|
+
1. CLI verb (or MCP tool) builds `session = Session.for(store, role:)` then `session.get(key)`.
|
|
113
|
+
2. `Session#get` dispatches to `Application::Read::Get.call(key, session:, ctx:, caps:)`.
|
|
114
|
+
3. `Read::Get::Impl#call` resolves the path through `caps.manifest`, reads bytes via `caps.file_store`, parses the envelope.
|
|
115
|
+
4. Looks up the refresh policy via `caps.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
|
|
61
116
|
5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
|
|
62
117
|
|
|
63
|
-
`
|
|
118
|
+
`session.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
|
|
64
119
|
|
|
65
|
-
## Write path (`
|
|
120
|
+
## Write path (`session.put(key, ...)`)
|
|
66
121
|
|
|
67
|
-
1. CLI verb calls `
|
|
68
|
-
2. `
|
|
69
|
-
3. Delegates persistence to `
|
|
70
|
-
4. Publishes `:entry_put` via
|
|
122
|
+
1. CLI verb calls `session = Session.for(store, role:)` then `session.put(key, meta:, body:, content:, if_etag:)`.
|
|
123
|
+
2. `Write::Put::Impl#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
|
|
124
|
+
3. Delegates persistence to `session.envelope_writer.put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
|
|
125
|
+
4. Publishes `:entry_put` via `caps.events` with `ctx: session.hook_context`, `key:`, `envelope:`.
|
|
71
126
|
|
|
72
|
-
`
|
|
127
|
+
`Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit caps, `Authorizer` for authz, `Envelope::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
|
|
73
128
|
|
|
74
|
-
`
|
|
129
|
+
`Write::Mv` delegates the file-move + audit to `Envelope::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::Writer#write` directly — no `Put` bypass.
|
|
75
130
|
|
|
76
|
-
## Refresh path (`
|
|
131
|
+
## Refresh path (`session.refresh(key)`)
|
|
77
132
|
|
|
78
|
-
1. CLI `Verb::Refresh` builds `
|
|
79
|
-
2. `
|
|
80
|
-
- Resolves the manifest entry, looks up the intake handler via
|
|
81
|
-
- Publishes `:refresh_started` with
|
|
133
|
+
1. CLI `Verb::Refresh` builds `session = Session.for(store, role: "runner")` then calls `session.refresh(key)`.
|
|
134
|
+
2. `Write::RefreshWorker::Impl#run(key)`:
|
|
135
|
+
- Resolves the manifest entry, looks up the intake handler via `caps.rpc.callable(:resolve_intake, mentry.handler)`.
|
|
136
|
+
- Publishes `:refresh_started` with the hook context.
|
|
82
137
|
- Invokes the handler under a 30s thread-join deadline.
|
|
83
138
|
- On any error: publishes `:refresh_failed`, then re-raises.
|
|
84
|
-
- On success: applies `@authorizer.authorize_write!` and persists via `
|
|
85
|
-
3. `
|
|
139
|
+
- On success: applies `@authorizer.authorize_write!` and persists via `Envelope::Writer#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
|
|
140
|
+
3. `session.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
|
|
86
141
|
|
|
87
142
|
## Hook payload contract
|
|
88
143
|
|
|
89
|
-
|
|
144
|
+
Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) receive `ctx:` — a `Textus::Hooks::Context` that wraps the session and exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
|
|
145
|
+
|
|
146
|
+
RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `ReadCaps` or `WriteCaps` slice. They are gem-internal: the framework calls them, not user pub-sub.
|
|
147
|
+
|
|
148
|
+
## Agent surface (boot + pulse + MCP)
|
|
149
|
+
|
|
150
|
+
Agents and plugins talk to a textus store through three layers:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Session ──▶ memory (.textus/)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Two transports, one façade:
|
|
157
|
+
|
|
158
|
+
- **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
|
|
159
|
+
- **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, manifest_etag) is server-side.
|
|
160
|
+
|
|
161
|
+
Both transports call `Session.for(store, role:)`. No duplicate logic.
|
|
162
|
+
|
|
163
|
+
The agent loop (cadence guide in `docs/agent-integration.md`):
|
|
164
|
+
|
|
165
|
+
1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
|
|
166
|
+
2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
|
|
167
|
+
3. **On demand:** `get`, `put`, `propose`, `refresh`, `schema`, `rules`.
|
|
168
|
+
|
|
169
|
+
Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
|
|
170
|
+
|
|
171
|
+
## Hooks::EventBus event catalog
|
|
172
|
+
|
|
173
|
+
RPC (single handler, declares `caps:`):
|
|
174
|
+
- `resolve_intake(caps:, config:, args:)` — intake fetch handler.
|
|
175
|
+
- `transform_rows(caps:, rows:, config:)` — row transform for intakes.
|
|
176
|
+
- `validate(caps:)` — custom doctor validator.
|
|
177
|
+
|
|
178
|
+
Pub-sub (0..N handlers, declare `ctx:`):
|
|
179
|
+
- `entry_put(ctx:, key:, envelope:)`
|
|
180
|
+
- `entry_deleted(ctx:, key:)`
|
|
181
|
+
- `entry_refreshed(ctx:, key:, envelope:, change:)`
|
|
182
|
+
- `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
|
|
183
|
+
- `build_completed(ctx:, key:, envelope:, sources:)`
|
|
184
|
+
- `proposal_accepted(ctx:, key:, target_key:)`
|
|
185
|
+
- `proposal_rejected(ctx:, key:, target_key:)`
|
|
186
|
+
- `file_published(ctx:, key:, envelope:, source:, target:)`
|
|
187
|
+
- `store_loaded(ctx:)`
|
|
188
|
+
- `refresh_started(ctx:, key:, mode:)`
|
|
189
|
+
- `refresh_failed(ctx:, key:, error_class:, error_message:)`
|
|
190
|
+
- `refresh_backgrounded(ctx:, key:, started_at:, budget_ms:)`
|
|
191
|
+
|
|
192
|
+
Authoritative source: `lib/textus/hooks/event_bus.rb` `EVENTS`.
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,108 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
|
|
|
9
9
|
bump is a breaking change that requires a store migration; the gem version
|
|
10
10
|
tracks both additive improvements and breaking protocol bumps independently.
|
|
11
11
|
|
|
12
|
+
## 0.26.0 — 2026-05-28
|
|
13
|
+
|
|
14
|
+
### Breaking
|
|
15
|
+
- Split `Textus::Hooks::Bus` into `Textus::Hooks::EventBus` (pubsub) and `Textus::Hooks::RpcRegistry` (named callables). The `Hooks::Bus` constant is removed.
|
|
16
|
+
- Replaced `Textus::Application::Ports` with three capability records: `Textus::Application::ReadCaps`, `WriteCaps`, `HookCaps`.
|
|
17
|
+
- Renamed `Textus::Operations` to `Textus::Session`. Access via `store.session(role:)`. `Operations.for(store, ...)` is removed.
|
|
18
|
+
- Hook RPC callables (`resolve_intake`, `transform_rows`, `validate`) no longer accept `store:` — declare `caps:` (a `WriteCaps` for `resolve_intake`/`validate`, `ReadCaps` for `transform_rows`).
|
|
19
|
+
- Removed all `Manifest` top-level deprecation shims (`zones`, `entries`, `zone_writers`, `permission_for`, etc.). Use `manifest.data.*` / `manifest.policy.*` / `manifest.resolver.*` / `manifest.rules.*`.
|
|
20
|
+
- Moved `Textus::Application::Writes::EnvelopeReader`/`EnvelopeWriter` to `Textus::Application::Envelope::Reader`/`Writer`.
|
|
21
|
+
- Renamed `Textus::Application::Writes` → `Textus::Application::Write`; `Textus::Application::Reads` → `Textus::Application::Read`; `Textus::Application::Restructure` → `Textus::Application::Maintenance`.
|
|
22
|
+
- Merged `Textus::Application::Refresh::*` into `Textus::Application::Write::Refresh{Worker,Orchestrator,All}`.
|
|
23
|
+
- Moved `Textus::Application::Policy::Promotion` and predicates to `Textus::Domain::Policy::Promotion`/`Predicates`.
|
|
24
|
+
|
|
25
|
+
## 0.25.1 — 2026-05-28
|
|
26
|
+
|
|
27
|
+
### Internal refactors
|
|
28
|
+
|
|
29
|
+
- **ADR 0018**: `Manifest` is now a composition record over `Data`,
|
|
30
|
+
`Resolver`, `Policy`, `Rules`. Top-level methods like
|
|
31
|
+
`Manifest#permission_for` are deprecated; use
|
|
32
|
+
`manifest.policy.permission_for(zone)`. One-cycle bridge — shims
|
|
33
|
+
warn until 0.26.0.
|
|
34
|
+
|
|
35
|
+
- **ADR 0016**: Application use cases take a single `ports:` kwarg
|
|
36
|
+
bundling six adapters + the store root. Hook DSL callables that
|
|
37
|
+
declare `|store:|` continue to work with a one-shot deprecation
|
|
38
|
+
warning per (event, hook_name); declare `|ports:|` to silence it.
|
|
39
|
+
|
|
40
|
+
- **ADR 0017**: `Application::Writes::EnvelopeIO` split into
|
|
41
|
+
`EnvelopeReader` (parse) and `EnvelopeWriter` (put/delete/move
|
|
42
|
+
+ audit). Every public `EnvelopeWriter` method now ends with an
|
|
43
|
+
audit-row append — the write-without-audit failure mode is gone.
|
|
44
|
+
|
|
45
|
+
### Breaking (internal)
|
|
46
|
+
|
|
47
|
+
- `Operations#store` accessor removed. There is no clean deprecation
|
|
48
|
+
shim because `Ports` cannot reconstruct a `Store`. External
|
|
49
|
+
callers should use `ops.ports.X` directly.
|
|
50
|
+
|
|
51
|
+
- `Textus::Manifest::Entry::Base::PublishContext` struct shape
|
|
52
|
+
changed: `:store` removed, `:ports` + `:boot` added. Affects
|
|
53
|
+
third-party plugins that build custom derived entries.
|
|
54
|
+
|
|
55
|
+
- `transform_context` passed to `transform_rows` RPC callables is
|
|
56
|
+
now an `Application::Ports`, not a `Store`. Transforms that treat
|
|
57
|
+
it as opaque continue to work; transforms that reach `.x` need
|
|
58
|
+
updates.
|
|
59
|
+
|
|
60
|
+
No CLI verb signatures changed. No wire envelopes changed.
|
|
61
|
+
Protocol remains `textus/3`.
|
|
62
|
+
|
|
63
|
+
## 0.25.0 — 2026-05-28
|
|
64
|
+
|
|
65
|
+
### Added (additive — backward-compatible pulse fields)
|
|
66
|
+
- `pulse.manifest_etag` — sha256 of `manifest.yaml`; lets agents detect contract drift without a second verb.
|
|
67
|
+
- `pulse.next_due_at` — soonest `next_due_at` across all entries with a refresh policy. Schedulers sleep until this timestamp instead of polling.
|
|
68
|
+
- `pulse.hook_errors` — recent hook failures since cursor; bounded in-memory ring on `Hooks::Bus#error_log` (default 256).
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
- `Application::Reads::Freshness` memoizes the evaluator verdict by `(key, last_refreshed_at)` per request — pulse no longer pays O(N) evaluator calls when nothing has changed.
|
|
72
|
+
- `Application::Refresh::Orchestrator` gains a cooperative-cancel fallback for `RefreshTimed` when `fork(2)` is unavailable (Windows). Previously degraded to `Failed("timed_sync requires fork")`; now executes within the budget on a Thread, killing it on budget exceeded.
|
|
73
|
+
|
|
74
|
+
### Protocol
|
|
75
|
+
- No wire-format change. `textus/3` envelopes are unchanged. Pulse fields are additive — existing consumers ignoring unknown keys continue to work.
|
|
76
|
+
|
|
77
|
+
## 0.24.0 — 2026-05-28
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
- **Context-structure ergonomics** (ADR 0015 Phase 2):
|
|
81
|
+
- `textus key mv --prefix OLD NEW` — bulk rename leaves under a prefix; preserves UIDs.
|
|
82
|
+
- `textus key delete --prefix P` — bulk delete leaves.
|
|
83
|
+
- `textus zone mv FROM TO` — rename a zone; refuses if destination exists; rewrites manifest + moves files.
|
|
84
|
+
- `textus rule lint --against=FILE` — diff candidate manifest YAML's `rules:` block against the live manifest.
|
|
85
|
+
- `textus migrate PLAN.yaml` — run a multi-op declarative migration plan (ops: `key_mv_prefix`, `key_delete_prefix`, `zone_mv`).
|
|
86
|
+
- All five operations also surface as MCP tools (`key_mv_prefix`, `key_delete_prefix`, `zone_mv`, `rule_lint`, `migrate`).
|
|
87
|
+
- `Textus::Application::Restructure` module with `Plan` value object and one use case per operation.
|
|
88
|
+
|
|
89
|
+
### Protocol
|
|
90
|
+
- No wire-format change. `textus/3` envelopes are unchanged.
|
|
91
|
+
|
|
92
|
+
## 0.23.0 — 2026-05-28
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
- **Agent gate (MCP transport).** `textus mcp serve` — stdio JSON-RPC 2.0
|
|
96
|
+
server speaking MCP draft 2024-11-05. Wraps `Textus::Operations` as ten
|
|
97
|
+
auto-derived tools (`boot`, `tick`, `find`, `read`, `write`, `propose`,
|
|
98
|
+
`refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
|
|
99
|
+
role, manifest_etag) held server-side. Manifest drift surfaces as
|
|
100
|
+
`ContractDrift` (-32001); cursor expiry as `CursorExpired` (-32002).
|
|
101
|
+
See [`docs/mcp.md`](docs/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
|
|
102
|
+
- `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
|
|
103
|
+
zero `textus <verb>` shell strings remain in plugin markdown.
|
|
104
|
+
|
|
105
|
+
### Changed (docs)
|
|
106
|
+
- `ARCHITECTURE.md`: fixed stale `registry` references (now `bus`),
|
|
107
|
+
added Agent Surface section and complete Hooks::Bus event catalog.
|
|
108
|
+
- `docs/agent-integration.md`: documents three transports (CLI, Ruby API,
|
|
109
|
+
MCP); points agent authors at the MCP transport by default.
|
|
110
|
+
|
|
111
|
+
### Protocol
|
|
112
|
+
- No wire-format change. `textus/3` envelopes are unchanged.
|
|
113
|
+
|
|
12
114
|
## 0.22.0 — 2026-05-28
|
|
13
115
|
|
|
14
116
|
### Changed (internal — no manifest-schema impact)
|
data/README.md
CHANGED
|
@@ -79,7 +79,7 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
|
|
|
79
79
|
|
|
80
80
|
- **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/output/marketplace.json | jq .` works without going through textus — the in-store file *is* the consumer-shaped artifact. Structured outputs carry `_meta` at the top level (`generated_at`, `from`, `template`, `transform`).
|
|
81
81
|
- **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
|
|
82
|
-
- **
|
|
82
|
+
- **Build and publish in one pass.** `Application::Write::Publish` materializes generator-zone entries and copies nested leaves to their `publish_each` targets. The `textus build` CLI verb dispatches to it; the wire envelope is unchanged.
|
|
83
83
|
- **Typed envelopes (v0.14.0).** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
|
|
84
84
|
- **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
|
|
85
85
|
- **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key normalize --dry-run|--write` rewrites existing stores with illegal segments deterministically.
|
data/SPEC.md
CHANGED
|
@@ -888,10 +888,10 @@ Given a review entry `review.identity.self.patch` proposing a change to `identit
|
|
|
888
888
|
|
|
889
889
|
Textus internals are organized into four layers. The dependency rule is one-way — each layer may only import from the layer beneath it.
|
|
890
890
|
|
|
891
|
-
- **Interface** (`lib/textus/cli/`) — CLI verbs. Parses flags, calls a use case, formats JSON.
|
|
892
|
-
- **Application** (`lib/textus/application/`) — Use cases: `
|
|
893
|
-
- **Domain** (`lib/textus/domain/`) — Pure values: `Freshness::Policy`, `Action`, `Outcome`, `
|
|
894
|
-
- **Infrastructure** (`lib/textus/infra/`) — Adapters: `
|
|
891
|
+
- **Interface** (`lib/textus/cli/`, `lib/textus/mcp/`) — CLI verbs and the MCP gate. Parses flags / RPC, calls a use case, formats JSON.
|
|
892
|
+
- **Application** (`lib/textus/application/`) — Use cases: `Read::Get`, `Write::Put`, `Write::RefreshWorker`, `Write::RefreshOrchestrator`, `Write::RefreshAll`, `Maintenance::Migrate`, etc. Orchestrate domain + infra; no business rules.
|
|
893
|
+
- **Domain** (`lib/textus/domain/`) — Pure values: `Authorizer`, `Permission`, `Freshness::{Policy,Verdict,Evaluator}`, `Action`, `Outcome`, `Sentinel`, `Staleness`. No I/O, no globals, testable without disk.
|
|
894
|
+
- **Infrastructure** (`lib/textus/infra/`) — Adapters: `Storage::FileStore`, `AuditLog`, `AuditSubscriber`, `Publisher`, `Clock`, `Refresh::Lock`, `Refresh::Detached`, `BuildLock`. Wrap OS / library primitives.
|
|
895
895
|
|
|
896
896
|
The `lib/textus/store/`, `lib/textus/manifest/`, `lib/textus/hooks/` namespaces are infrastructure adapters that predate this split and remain at their existing paths for backward-compat with the plugin DSL.
|
|
897
897
|
|
|
@@ -899,14 +899,14 @@ Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:res
|
|
|
899
899
|
|
|
900
900
|
Both read and write paths flow through the application layer:
|
|
901
901
|
|
|
902
|
-
- **Reads** flow through `Application::
|
|
903
|
-
- **Writes** flow through `Application::
|
|
904
|
-
- `Application::Context` is the
|
|
905
|
-
- `Textus::
|
|
906
|
-
use
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
`
|
|
902
|
+
- **Reads** flow through `Application::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `caps:` slice and an `Application::Context`.
|
|
903
|
+
- **Writes** flow through `Application::Write::{Put,Delete,Mv,Accept,Reject,Publish,RefreshWorker}`. Permission checks happen at the use-case layer (via `Domain::Authorizer#authorize_write!`); the audit-append invariant lives in `Application::Envelope::Writer`.
|
|
904
|
+
- `Application::Context` is the slim request record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from a `Caps` record (Read/Write/Hook), not from the Context.
|
|
905
|
+
- `Textus::Session` is the factory CLI verbs and the MCP gate use to dispatch
|
|
906
|
+
use cases. `Session.for(store, role:)` returns a per-call object exposing one
|
|
907
|
+
method per registered use case (`#put`, `#get`, `#refresh`, …); methods are
|
|
908
|
+
generated from `Application::UseCase.entries` so adding a use case is a
|
|
909
|
+
single `UseCase.register(...)` line.
|
|
910
910
|
|
|
911
911
|
See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
|
|
912
912
|
|
data/docs/conventions.md
CHANGED
|
@@ -126,6 +126,16 @@ Build always uses the pure path; injecting refresh into materialization caused t
|
|
|
126
126
|
|
|
127
127
|
For multi-writer environments, **always pass `if_etag`** on `put`. The gem treats etag-less writes as last-writer-wins on purpose (single-writer scripts, fresh-file creation), but anything resembling a daemon or a long-running agent should round-trip the etag.
|
|
128
128
|
|
|
129
|
+
## Application layering
|
|
130
|
+
|
|
131
|
+
The application layer is organised around three patterns — `Manifest` as a composition record, capability slices (`Caps`) handed to use cases, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0020](architecture/decisions/0020-capability-records.md), and [ADR 0021](architecture/decisions/0021-session-and-module-use-cases.md).
|
|
132
|
+
|
|
133
|
+
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`. (The legacy top-level shims were removed in 0.26.0.)
|
|
134
|
+
- **Application use cases take `session:`, `ctx:`, `caps:`** and are registered with `Application::UseCase.register(:verb, mod, caps: :read|:write)`. Each use case is a module with a `self.call(...)` entry point and a nested `class Impl` for state. `Caps` is a `Data.define` slice of the Store (`ReadCaps`, `WriteCaps`, `HookCaps`) — use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer.
|
|
135
|
+
- **Write path is split**: `Application::Envelope::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Application::Envelope::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
|
|
136
|
+
|
|
137
|
+
The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
|
|
138
|
+
|
|
129
139
|
## Pairing with other tools
|
|
130
140
|
|
|
131
141
|
- **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
# Capability records: role-scoped slices of the Store handed to use cases.
|
|
4
|
+
# Zeitwerk maps this file to Textus::Application::Caps; the three
|
|
5
|
+
# concrete cap types are also promoted to the Application namespace for
|
|
6
|
+
# concise reference (Application::ReadCaps, etc.).
|
|
7
|
+
module Caps
|
|
8
|
+
ReadCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events)
|
|
9
|
+
|
|
10
|
+
WriteCaps = Data.define(
|
|
11
|
+
:manifest, :file_store, :schemas, :root,
|
|
12
|
+
:audit_log, :events, :authorizer
|
|
13
|
+
) do
|
|
14
|
+
def read
|
|
15
|
+
ReadCaps.new(
|
|
16
|
+
manifest: manifest, file_store: file_store, schemas: schemas, root: root,
|
|
17
|
+
audit_log: audit_log, events: events
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
HookCaps = Data.define(:events, :rpc, :manifest, :root)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Promote to Application namespace for concise reference.
|
|
26
|
+
ReadCaps = Caps::ReadCaps
|
|
27
|
+
WriteCaps = Caps::WriteCaps
|
|
28
|
+
HookCaps = Caps::HookCaps
|
|
29
|
+
|
|
30
|
+
def self.caps_from_store(store)
|
|
31
|
+
read = ReadCaps.new(
|
|
32
|
+
manifest: store.manifest, file_store: store.file_store,
|
|
33
|
+
schemas: store.schemas, root: store.root,
|
|
34
|
+
audit_log: store.audit_log, events: store.events
|
|
35
|
+
)
|
|
36
|
+
write = WriteCaps.new(
|
|
37
|
+
manifest: store.manifest, file_store: store.file_store,
|
|
38
|
+
schemas: store.schemas, root: store.root,
|
|
39
|
+
audit_log: store.audit_log, events: store.events,
|
|
40
|
+
authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest)
|
|
41
|
+
)
|
|
42
|
+
hook = HookCaps.new(
|
|
43
|
+
events: store.events, rpc: store.rpc,
|
|
44
|
+
manifest: store.manifest, root: store.root
|
|
45
|
+
)
|
|
46
|
+
[read, write, hook]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -7,8 +7,8 @@ module Textus
|
|
|
7
7
|
# writes should be suppressed (dry_run).
|
|
8
8
|
#
|
|
9
9
|
# Collaborators (manifest, file_store, bus, audit log, authorizer) are
|
|
10
|
-
# never read from Context — use cases
|
|
11
|
-
#
|
|
10
|
+
# never read from Context — use cases pull them from a Caps record
|
|
11
|
+
# (Read/Write/Hook) that Session derives from the Store.
|
|
12
12
|
Context = Data.define(:role, :correlation_id, :now, :dry_run) do
|
|
13
13
|
def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
|
|
14
14
|
new(
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Envelope
|
|
4
|
+
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
5
|
+
# bytes, parses them via the format strategy, and hands back an
|
|
6
|
+
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
7
|
+
# (existing-uid lookup for the uid-preservation step in #put).
|
|
8
|
+
#
|
|
9
|
+
# No audit, no events, no permission checks — those live one layer up.
|
|
10
|
+
class Reader
|
|
11
|
+
def initialize(file_store:, manifest:)
|
|
12
|
+
@file_store = file_store
|
|
13
|
+
@manifest = manifest
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read(key)
|
|
17
|
+
res = @manifest.resolver.resolve(key)
|
|
18
|
+
path = res.path
|
|
19
|
+
return nil unless @file_store.exists?(path)
|
|
20
|
+
|
|
21
|
+
mentry = res.entry
|
|
22
|
+
raw = @file_store.read(path)
|
|
23
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
24
|
+
Textus::Envelope.build(
|
|
25
|
+
key: key, mentry: mentry, path: path,
|
|
26
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
27
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def existing_uid(key)
|
|
32
|
+
env = read(key)
|
|
33
|
+
env&.uid
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def exists?(key)
|
|
39
|
+
@file_store.exists?(@manifest.resolver.resolve(key).path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|