textus 0.15.0 → 0.18.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 +14 -14
- data/CHANGELOG.md +313 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +24 -0
- data/lib/textus/application/reads/audit.rb +1 -1
- data/lib/textus/application/reads/blame.rb +3 -1
- data/lib/textus/application/reads/deps.rb +1 -1
- data/lib/textus/application/reads/freshness.rb +12 -3
- data/lib/textus/application/reads/get.rb +32 -8
- data/lib/textus/application/reads/get_or_refresh.rb +5 -5
- data/lib/textus/application/reads/list.rb +3 -1
- data/lib/textus/application/reads/published.rb +1 -1
- data/lib/textus/application/reads/rdeps.rb +1 -1
- data/lib/textus/application/reads/schema_envelope.rb +3 -1
- data/lib/textus/application/reads/stale.rb +1 -1
- data/lib/textus/application/reads/uid.rb +1 -1
- data/lib/textus/application/reads/validate_all.rb +6 -1
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +4 -1
- data/lib/textus/application/refresh/all.rb +8 -1
- data/lib/textus/application/refresh/orchestrator.rb +2 -3
- data/lib/textus/application/refresh/worker.rb +18 -15
- data/lib/textus/application/writes/accept.rb +12 -12
- data/lib/textus/application/writes/build.rb +3 -4
- data/lib/textus/application/writes/delete.rb +10 -15
- data/lib/textus/application/writes/envelope_io.rb +106 -0
- data/lib/textus/application/writes/mv.rb +25 -27
- data/lib/textus/application/writes/publish.rb +8 -9
- data/lib/textus/application/writes/put.rb +12 -16
- data/lib/textus/application/writes/reject.rb +10 -10
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -2
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +3 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -0
- data/lib/textus/cli/verb/hooks.rb +3 -0
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +3 -0
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +3 -1
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +3 -0
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +18 -27
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/hooks.rb +3 -1
- data/lib/textus/doctor/check/intake_registration.rb +3 -3
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +2 -2
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness/intake_check.rb +1 -1
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/dispatcher.rb +17 -9
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/hooks/registry.rb +4 -0
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +11 -9
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest.rb +4 -3
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/operations.rb +83 -16
- data/lib/textus/projection.rb +2 -2
- data/lib/textus/refresh.rb +1 -1
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +14 -13
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/operations/reads.rb +0 -56
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d7915be053bc7e858c821e0e8d8e8cebd3de6acde21dcd8f687ff3fa12db3d1
|
|
4
|
+
data.tar.gz: 792cd40df3e8046c954be84dc32d4e53a2b47cdfd9a21029c74953d39e182a87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 457d079d8ebf25922f9389086ec2de99b96808f23fba7f600655fb2ef055a5a987dd1ea63924aaf9904c7f4e600b4aee33a8c2082028bbb9025068aa00de3b0e
|
|
7
|
+
data.tar.gz: a1192a6027b80582723b0cb4679b870ed78101864e22700ba7630308912f46000e774dbd69c589418516047dff2f8cdbf7a26e69e47d3a7ffcef89f917397a23
|
data/ARCHITECTURE.md
CHANGED
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
```
|
|
4
4
|
┌─ Interface ────────────────────────────────────────────────┐
|
|
5
5
|
│ CLI verbs: ops = Operations.for(store, role:) │
|
|
6
|
-
│ ops
|
|
7
|
-
│
|
|
8
|
-
│ ops.refresh.<name>.call(...) │
|
|
6
|
+
│ ops.<name>(...) # flat methods, one per use │
|
|
7
|
+
│ # case (put/get/refresh/…) │
|
|
9
8
|
└──────────────────────┬─────────────────────────────────────┘
|
|
10
9
|
│
|
|
11
10
|
┌─ Application ────────▼─────────────────────────────────────┐
|
|
12
11
|
│ Context (per-request: store, role, correlation, │
|
|
13
12
|
│ clock, dry_run; can_read?/can_write?; │
|
|
13
|
+
│ authorize_read!/authorize_write!; bus; │
|
|
14
14
|
│ Context.system(store) for infra path) │
|
|
15
|
-
│ Operations (facade
|
|
15
|
+
│ Operations (flat facade — memoized use cases) │
|
|
16
16
|
│ │
|
|
17
17
|
│ reads/{get,list,where,uid,schema_envelope,deps,rdeps, │
|
|
18
18
|
│ published,stale,validate_all,freshness,audit, │
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
imports. Application imports Domain + Infra (via ports).
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
## Read path (`ops.
|
|
51
|
+
## Read path (`ops.get(key)`)
|
|
52
52
|
|
|
53
|
-
1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.
|
|
54
|
-
2. `Operations
|
|
53
|
+
1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
|
|
54
|
+
2. `Operations#get` delegates to a memoized `Application::Reads::Get.new(ctx:, orchestrator:)` instance bound to the request context.
|
|
55
55
|
3. `Reads::Get#call(key)` reads the bare envelope from disk via `@ctx.store.reader.read_raw_envelope(key)`.
|
|
56
56
|
4. Resolves the manifest rules for the key via `@ctx.store.manifest.rules_for(key)` and extracts the `refresh` policy.
|
|
57
57
|
5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
|
|
@@ -63,10 +63,10 @@
|
|
|
63
63
|
- `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_backgrounded`, fork+detach child, return `Outcome::Detached`
|
|
64
64
|
9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
|
|
65
65
|
|
|
66
|
-
## Write path (`ops.
|
|
66
|
+
## Write path (`ops.put(key, ...)`)
|
|
67
67
|
|
|
68
|
-
1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.
|
|
69
|
-
2. `Writes::Put#call` validates the key, resolves the manifest entry, and
|
|
68
|
+
1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag:, suppress_events:)`.
|
|
69
|
+
2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@ctx.authorize_write!(mentry)` — raises `WriteForbidden` (carrying the zone's writers list) if denied.
|
|
70
70
|
3. Delegates raw I/O to `Store::Writer#write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag:)`, which:
|
|
71
71
|
- Resolves the path via `Manifest#resolve`
|
|
72
72
|
- Serializes via `Entry.for_format(...).serialize(...)`
|
|
@@ -74,13 +74,13 @@
|
|
|
74
74
|
- Etag-checks if `if_etag:` provided (raises `EtagMismatch` on conflict)
|
|
75
75
|
- Writes to disk via `File.binwrite`
|
|
76
76
|
- Appends the audit row
|
|
77
|
-
4. On success, publishes `:entry_put` via
|
|
77
|
+
4. On success, publishes `:entry_put` via `@ctx.bus`, with `store: @ctx.with_role(@ctx.role)`, `key:`, `envelope:`, `correlation_id:`.
|
|
78
78
|
|
|
79
|
-
The same pattern applies to `Writes::{Delete,Mv,Accept,Reject,Build,Publish}`: each takes a `Context`,
|
|
79
|
+
The same pattern applies to `Writes::{Delete,Mv,Accept,Reject,Build,Publish}`: each takes a `Context`, calls `ctx.authorize_write!` (Mv authorizes both source and destination zones), delegates raw I/O to `Store::Writer` or `Infra::Publisher`, and fires the matching event through `ctx.bus`.
|
|
80
80
|
|
|
81
|
-
## Refresh path (`ops.refresh
|
|
81
|
+
## Refresh path (`ops.refresh(key)`)
|
|
82
82
|
|
|
83
|
-
1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh
|
|
83
|
+
1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh(key)`.
|
|
84
84
|
2. `Refresh::Worker#run(key)`:
|
|
85
85
|
- Resolves the manifest entry, looks up the intake handler via `store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
|
|
86
86
|
- Publishes `:refresh_started` via the bus.
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,319 @@ 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.18.0 — 2026-05-27
|
|
13
|
+
|
|
14
|
+
Port extraction finishes the hexagonal trajectory. `Store::Reader` and
|
|
15
|
+
`Store::Writer` were disguised application code under an infra
|
|
16
|
+
namespace; this release replaces them with a true I/O port
|
|
17
|
+
(`Infra::Storage::FileStore`, bytes only) and lifts their orchestration
|
|
18
|
+
into `Application::Writes::EnvelopeIO` and the existing
|
|
19
|
+
`Application::Reads::*`. `Store` becomes a composition root: nothing
|
|
20
|
+
else. Wire format (`textus/3`) and audit log NDJSON line format are
|
|
21
|
+
byte-identical to 0.17.0 — every change is gem-side.
|
|
22
|
+
|
|
23
|
+
### Breaking (Ruby API)
|
|
24
|
+
|
|
25
|
+
- **`Store::Reader` and `Store::Writer` are deleted.** Both classes
|
|
26
|
+
were doing application work (serialize, UID inject, name-match,
|
|
27
|
+
schema validate, etag negotiate, audit append, event publish) under
|
|
28
|
+
an infra label. Their methods move to flat `Operations` calls:
|
|
29
|
+
```
|
|
30
|
+
store.reader.get(key) → Textus::Operations#get(key)
|
|
31
|
+
store.reader.read_raw_envelope(key) → Textus::Operations#get(key)
|
|
32
|
+
store.reader.list(prefix:, zone:) → Textus::Operations#list(prefix:, zone:)
|
|
33
|
+
store.reader.where(key) → Textus::Operations#where(key)
|
|
34
|
+
store.reader.uid(key) → Textus::Operations#uid(key)
|
|
35
|
+
store.reader.schema_envelope(key) → Textus::Operations#schema_envelope(key)
|
|
36
|
+
store.reader.published → Textus::Operations#published
|
|
37
|
+
store.reader.stale(...) → Textus::Operations#stale(...)
|
|
38
|
+
store.reader.deps(key) → Textus::Operations#deps(key)
|
|
39
|
+
store.reader.rdeps(key) → Textus::Operations#rdeps(key)
|
|
40
|
+
store.reader.validate_all → Textus::Operations#validate_all
|
|
41
|
+
|
|
42
|
+
store.writer.write_envelope_to_disk → Textus::Operations#put(key, ...)
|
|
43
|
+
store.writer.delete_envelope_from_disk → Textus::Operations#delete(key, ...)
|
|
44
|
+
```
|
|
45
|
+
- **`Store#schema_for(name)` is deleted.** Schemas live on a dedicated
|
|
46
|
+
cache:
|
|
47
|
+
```
|
|
48
|
+
store.schema_for(name) → store.schemas.fetch(name)
|
|
49
|
+
```
|
|
50
|
+
- **Infra/Domain relocations.** Files that were `Store::*` because the
|
|
51
|
+
namespace was a catch-all now live in the layer they belong to:
|
|
52
|
+
```
|
|
53
|
+
Textus::Store::AuditLog → Textus::Infra::AuditLog
|
|
54
|
+
Textus::Store::Sentinel → Textus::Domain::Sentinel
|
|
55
|
+
Textus::Store::Staleness → Textus::Domain::Staleness
|
|
56
|
+
Textus::Store::Validator → Textus::Application::Reads::Validator
|
|
57
|
+
```
|
|
58
|
+
- **Write use-case constructors take `envelope_io:`.**
|
|
59
|
+
`Application::Writes::Put.new(ctx:, envelope_io:)` — same for
|
|
60
|
+
`Delete` and `Mv`. External code that constructed write use cases
|
|
61
|
+
directly adds the kwarg.
|
|
62
|
+
- **Note.** Most embedders construct use cases via
|
|
63
|
+
`Textus::Operations.for(store)`. That constructor still works
|
|
64
|
+
without changes — `Operations#for` wires `envelope_io:` from the
|
|
65
|
+
store. Embedders on the recommended path see no breakage.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **`Textus::Infra::Storage::FileStore`** — pure I/O port. `read`,
|
|
70
|
+
`write`, `delete`, `exists?`, `etag` — bytes in, bytes out. No
|
|
71
|
+
serialization, no schema, no manifest, no events. The seam that
|
|
72
|
+
makes non-file storage backends possible.
|
|
73
|
+
- **`Textus::Schemas`** — eager-loading schema cache. Reads the
|
|
74
|
+
`_schemas/**` zone at boot, exposes `fetch(name)` and `each`.
|
|
75
|
+
Replaces the on-demand `Store#schema_for` lookup.
|
|
76
|
+
- **`Textus::Application::Writes::EnvelopeIO`** — the write pipeline
|
|
77
|
+
collaborator. Serializes the envelope, validates against its
|
|
78
|
+
schema, negotiates etag, writes via `FileStore`, appends to audit,
|
|
79
|
+
publishes the event. The shared orchestration that `Put`,
|
|
80
|
+
`Delete`, and `Mv` previously duplicated through `Store::Writer`.
|
|
81
|
+
|
|
82
|
+
### Internal
|
|
83
|
+
|
|
84
|
+
- **`Store` is a composition root.** Its responsibilities are
|
|
85
|
+
construction and exposure: `manifest`, `schemas`, `file_store`,
|
|
86
|
+
`audit_log`, `bus`, `registry`, `root`. No `reader`, no `writer`,
|
|
87
|
+
no `schema_for`. Hook loading (`load_hooks`) and operations
|
|
88
|
+
exposure (`operations`) remain — both delegate to dedicated
|
|
89
|
+
collaborators.
|
|
90
|
+
- **Read use cases read from `file_store`/`manifest`/`schemas`
|
|
91
|
+
directly.** `Reads::Get`, `Reads::List`, `Reads::Where`,
|
|
92
|
+
`Reads::Stale`, `Reads::Deps`, etc., no longer route through a
|
|
93
|
+
reader facade. The path is `Operations → use case → ports`.
|
|
94
|
+
|
|
95
|
+
### Wire format / audit format
|
|
96
|
+
|
|
97
|
+
Unchanged. `textus/3` envelopes written by 0.17.0 round-trip through
|
|
98
|
+
0.18.0 byte-for-byte; audit log NDJSON lines are bidirectionally
|
|
99
|
+
compatible.
|
|
100
|
+
|
|
101
|
+
### Migrating from 0.17
|
|
102
|
+
|
|
103
|
+
Mechanical for embedders; transparent for CLI users.
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
# Reads
|
|
107
|
+
store.reader.get(key) → ops.get(key)
|
|
108
|
+
store.reader.list(prefix: x) → ops.list(prefix: x)
|
|
109
|
+
store.reader.stale(...) → ops.stale(...)
|
|
110
|
+
# (and the rest of the table above)
|
|
111
|
+
|
|
112
|
+
# Writes — recommended path stays the same
|
|
113
|
+
ops.put(key, body: x) # unchanged
|
|
114
|
+
|
|
115
|
+
# Schemas
|
|
116
|
+
store.schema_for(name) → store.schemas.fetch(name)
|
|
117
|
+
|
|
118
|
+
# Renames
|
|
119
|
+
Textus::Store::AuditLog → Textus::Infra::AuditLog
|
|
120
|
+
Textus::Store::Sentinel → Textus::Domain::Sentinel
|
|
121
|
+
Textus::Store::Staleness → Textus::Domain::Staleness
|
|
122
|
+
Textus::Store::Validator → Textus::Application::Reads::Validator
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## 0.17.0 — 2026-05-27
|
|
126
|
+
|
|
127
|
+
API and policy reshape. The public Ruby surface flattens, authorization
|
|
128
|
+
moves from seven duplicated blocks into one helper on `Application::Context`,
|
|
129
|
+
and the only thread-local in the library is gone. Wire format (`textus/3`)
|
|
130
|
+
and CLI JSON output are byte-identical to 0.16.0. Every change is gem-side.
|
|
131
|
+
|
|
132
|
+
### Breaking (Ruby API)
|
|
133
|
+
|
|
134
|
+
- **`Operations` is flat.** The `Operations#reads`, `Operations#writes`,
|
|
135
|
+
and `Operations#refresh` namespace shells are removed; every use case
|
|
136
|
+
is now a directly-named method on `Operations` itself. Callers that
|
|
137
|
+
typed three levels of indirection plus `.call` switch to a single
|
|
138
|
+
method call:
|
|
139
|
+
```ruby
|
|
140
|
+
ops.writes.put.call(key, body: x) → ops.put(key, body: x)
|
|
141
|
+
ops.reads.get.call(key) → ops.get(key)
|
|
142
|
+
ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
|
|
143
|
+
ops.refresh.worker.call(key) → ops.refresh(key)
|
|
144
|
+
ops.refresh.all.call(prefix:, …) → ops.refresh_all(prefix:, …)
|
|
145
|
+
```
|
|
146
|
+
Internal use-case instances are memoized via `||=`. `Operations#with_role`
|
|
147
|
+
returns a fresh `Operations` with no shared memoization.
|
|
148
|
+
- **`Operations::Reads`, `Operations::Writes`, `Operations::Refresh`** —
|
|
149
|
+
the shell classes — are deleted. External code that named them
|
|
150
|
+
directly (rare) must move to the flat methods on `Operations`.
|
|
151
|
+
- **Top-level `Textus.on(event, name) { ... }` is removed.** Hook files
|
|
152
|
+
now wrap registration in a `Textus.hook` block that receives the
|
|
153
|
+
store's registry:
|
|
154
|
+
```ruby
|
|
155
|
+
# before
|
|
156
|
+
Textus.on(:entry_put, "audit") { |store:, key:, **| ... }
|
|
157
|
+
# after
|
|
158
|
+
Textus.hook do |reg|
|
|
159
|
+
reg.on(:entry_put, "audit") { |store:, key:, **| ... }
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
Multiple `reg.on` lines under one `Textus.hook` block is idiomatic.
|
|
163
|
+
- **`Textus.with_registry` is removed.** Tests instantiate
|
|
164
|
+
`Textus::Hooks::Registry.new` and call `reg.on(...)` directly — no
|
|
165
|
+
`around` block, no thread-local cleanup.
|
|
166
|
+
- **`Textus::Hooks::Loader.current_registry` is removed.** It was the
|
|
167
|
+
thread-local read accessor; nothing replaces it because no thread-
|
|
168
|
+
local remains.
|
|
169
|
+
- **Write use-case constructors lose `bus:`.** `Application::Writes::*`
|
|
170
|
+
classes pull the bus from `@ctx.bus` instead of taking it as a kwarg.
|
|
171
|
+
External code that constructed `Writes::Put.new(ctx:, bus:)` directly
|
|
172
|
+
drops the `bus:` argument.
|
|
173
|
+
|
|
174
|
+
### Added
|
|
175
|
+
|
|
176
|
+
- **`Application::Context#authorize_write!(mentry)`** — raises
|
|
177
|
+
`WriteForbidden` (with the zone's writers list in `details`) when the
|
|
178
|
+
bound role lacks write permission. Returns `nil` on success. Replaces
|
|
179
|
+
the seven duplicated `unless can_write? ... raise WriteForbidden`
|
|
180
|
+
blocks across `Writes::{Put,Delete,Mv,Accept,Reject,Build,Publish}`.
|
|
181
|
+
- **`Application::Context#authorize_read!(mentry)`** — mirror of
|
|
182
|
+
`authorize_write!`. Raises a new `ReadForbidden` (code `read_forbidden`,
|
|
183
|
+
exit 1, details: `key`, `zone`, `readers`).
|
|
184
|
+
- **`Application::Context#bus`** — returns `store.bus`. Use cases publish
|
|
185
|
+
events through `@ctx.bus`; the prior `@ctx.store.bus` reach-through is
|
|
186
|
+
no longer used in-tree.
|
|
187
|
+
- **`Textus::ReadForbidden`** error class. Symmetric with `WriteForbidden`.
|
|
188
|
+
- **`Textus.hook(&blk)`** — appends the supplied block to a mutex-
|
|
189
|
+
guarded module-level queue. The store-scoped loader drains and invokes
|
|
190
|
+
each block with its registry.
|
|
191
|
+
- **`Textus.drain_hook_blocks`** — public for tests; returns and clears
|
|
192
|
+
the queued blocks under the same mutex.
|
|
193
|
+
- **`Textus::Hooks::Registry#on`** — already the canonical instance API
|
|
194
|
+
since 0.11; explicitly documented as the registration primitive now
|
|
195
|
+
that the top-level shim is gone.
|
|
196
|
+
|
|
197
|
+
### Internal
|
|
198
|
+
|
|
199
|
+
- **`Application::Writes::Mv`** now authorizes both source and
|
|
200
|
+
destination zones. The prior code authorized only the source; the
|
|
201
|
+
centralized `authorize_write!` made the second call a one-liner and
|
|
202
|
+
the gap obvious.
|
|
203
|
+
- **`Hooks::Builtin.register_all`** takes a `registry:` argument and
|
|
204
|
+
calls `registry.on(...)` directly. No thread-local read.
|
|
205
|
+
- **`Hooks::Loader`** is now a per-store class constructed with
|
|
206
|
+
`registry:`. `#load_dir(path)` walks the directory, `load`s each
|
|
207
|
+
`.rb`, then drains `Textus.drain_hook_blocks` and invokes each with
|
|
208
|
+
the registry. Two threads loading two stores concurrently are safe
|
|
209
|
+
because each `load_dir` drains around its own file walk under the
|
|
210
|
+
module-level mutex.
|
|
211
|
+
- **`Doctor::Check::Hooks`** reads `store.registry` directly; no
|
|
212
|
+
thread-local indirection.
|
|
213
|
+
- **`Store#load_hooks`** is a two-liner: construct a `Loader` with
|
|
214
|
+
`@registry`, call `load_dir` against `.textus/hooks/`.
|
|
215
|
+
- **Reads/refresh paths** use `@ctx.bus` instead of `@ctx.store.bus`.
|
|
216
|
+
Same object; the indirection is gone.
|
|
217
|
+
|
|
218
|
+
### Migrating from 0.16
|
|
219
|
+
|
|
220
|
+
Mechanical, sed-friendly. The CLI shape is unchanged — only embedders
|
|
221
|
+
and hook authors need to do anything.
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
# Operations: flat surface
|
|
225
|
+
ops.writes.put.call(key, body: x) → ops.put(key, body: x)
|
|
226
|
+
ops.reads.get.call(key) → ops.get(key)
|
|
227
|
+
ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
|
|
228
|
+
ops.refresh.worker.call(key) → ops.refresh(key) # via Operations#refresh
|
|
229
|
+
ops.refresh.all.call(...) → ops.refresh_all(...)
|
|
230
|
+
|
|
231
|
+
# Hooks: explicit registration
|
|
232
|
+
Textus.on(:entry_put, "x") { |e| ... }
|
|
233
|
+
→
|
|
234
|
+
Textus.hook do |reg|
|
|
235
|
+
reg.on(:entry_put, "x") { |e| ... }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Tests: no more thread-local scope
|
|
239
|
+
around { |ex| Textus.with_registry(reg) { ex.run } } # delete
|
|
240
|
+
Textus.on(:resolve_intake, :x) { ... } # → reg.on(:resolve_intake, :x) { ... }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
If you constructed `Writes::Put` (or any other write use case)
|
|
244
|
+
directly, drop the `bus:` kwarg from the constructor call. If you
|
|
245
|
+
constructed `Hooks::Loader` directly, the new signature is
|
|
246
|
+
`Loader.new(registry:)` and the API is `loader.load_dir(path)`.
|
|
247
|
+
|
|
248
|
+
### ADRs
|
|
249
|
+
|
|
250
|
+
- [ADR 0010 — Flat Operations API](docs/architecture/decisions/0010-flat-operations-api.md)
|
|
251
|
+
- [ADR 0011 — Authorize-bang in Context](docs/architecture/decisions/0011-authorize-bang-in-context.md)
|
|
252
|
+
- [ADR 0012 — Explicit hook registration](docs/architecture/decisions/0012-explicit-hook-registration.md)
|
|
253
|
+
|
|
254
|
+
## 0.16.0 — 2026-05-26
|
|
255
|
+
|
|
256
|
+
Type cleanup and infra glue. Wire format (`textus/3`) and CLI JSON output
|
|
257
|
+
are byte-identical to 0.15.0. Every change is gem-side.
|
|
258
|
+
|
|
259
|
+
### Breaking (Ruby API)
|
|
260
|
+
|
|
261
|
+
- **`Envelope#freshness`** is now a `Textus::Domain::Freshness` value (a
|
|
262
|
+
`Data.define(:stale, :refreshing, :reason, :refresh_error, :checked_at,
|
|
263
|
+
:ttl_remaining_ms)`), not a `Hash`. Field access replaces string-key
|
|
264
|
+
lookup: `env.freshness.stale` (was `env.freshness["stale"]`). The
|
|
265
|
+
field formerly emitted as `"stale_reason"` on the wire is named
|
|
266
|
+
`:reason` on the value object; `Freshness#to_h_for_wire` still emits
|
|
267
|
+
`"stale_reason"`, so JSON output is unchanged. New fields
|
|
268
|
+
(`:checked_at`, `:ttl_remaining_ms`) are gem-side only and not on the
|
|
269
|
+
wire.
|
|
270
|
+
- **`Manifest#resolve(key)`** now returns a `Textus::Manifest::Resolution`
|
|
271
|
+
value (`Data.define(:entry, :path, :remaining)`) instead of an
|
|
272
|
+
`[entry, path, remaining]` tuple. Callers that destructured the array
|
|
273
|
+
must switch to field access: `res = manifest.resolve(key); res.entry`.
|
|
274
|
+
Raises `UnknownKey` on miss (unchanged).
|
|
275
|
+
- **`Textus::Store.mint_uid`** is removed. Use `Textus::Uid.mint`. A
|
|
276
|
+
companion `Textus::Uid.valid?(str)` predicate is added.
|
|
277
|
+
- **`Hooks::Dispatcher.new(audit_log:)`** no longer accepts
|
|
278
|
+
`audit_log:`. The dispatcher is now a pure pub/sub. Hook-error audit
|
|
279
|
+
rows are written by `Textus::Infra::AuditSubscriber`, which `Store`
|
|
280
|
+
attaches at boot. The NDJSON audit line format is unchanged
|
|
281
|
+
byte-for-byte.
|
|
282
|
+
|
|
283
|
+
### Added
|
|
284
|
+
|
|
285
|
+
- `Textus::Domain::Freshness` — typed envelope-annotation value object.
|
|
286
|
+
- `Textus::Manifest::Resolution` — typed key-resolution value object.
|
|
287
|
+
- `Textus::Uid` — `.mint` / `.valid?` for the 16-hex UID format.
|
|
288
|
+
- `Textus::Infra::AuditSubscriber` — attaches to the event bus and
|
|
289
|
+
writes the `verb: "event_error"` audit row when a user hook raises.
|
|
290
|
+
- `CLI::Verb.command_name "X"` and `CLI::Verb.parent_group Group::Y`
|
|
291
|
+
DSL. Adding a new CLI verb is now a single declaration in the verb's
|
|
292
|
+
own file; the top-level `VERBS` table and group subcommand maps are
|
|
293
|
+
auto-derived from descendants. Help-output ordering is alphabetical
|
|
294
|
+
by command name.
|
|
295
|
+
|
|
296
|
+
### Changed
|
|
297
|
+
|
|
298
|
+
- `CLI::Group` no longer exposes the `cli_name` writer — use
|
|
299
|
+
`command_name` (the prior `cli_name` reader is removed).
|
|
300
|
+
- `Application::Reads::Get` and `Reads::GetOrRefresh` construct
|
|
301
|
+
`Freshness` values directly; their public signatures are unchanged.
|
|
302
|
+
|
|
303
|
+
### Deprecated
|
|
304
|
+
|
|
305
|
+
- `Textus::CLI::VERBS` constant. Still resolves (via `const_missing` to
|
|
306
|
+
the auto-derived table) for backward compatibility; will be removed
|
|
307
|
+
in a future minor. Prefer `Textus::CLI.verbs`.
|
|
308
|
+
|
|
309
|
+
### Notes for embedders
|
|
310
|
+
|
|
311
|
+
- Group subcommand error messages now list subcommands alphabetically
|
|
312
|
+
(e.g., `key requires a subcommand: mv, normalize, uid` rather than
|
|
313
|
+
`mv, uid, normalize`).
|
|
314
|
+
- Lifecycle audit appends for `verb: "put"` / `"delete"` / `"rename"`
|
|
315
|
+
still flow through `Store::Writer` and `Application::Writes::Mv`.
|
|
316
|
+
Centralizing those in a lifecycle subscriber is deferred to 0.18
|
|
317
|
+
port-extraction; it requires event payloads to carry
|
|
318
|
+
`etag_before`/`etag_after`, which they don't yet.
|
|
319
|
+
|
|
320
|
+
### ADRs
|
|
321
|
+
|
|
322
|
+
- [ADR 0008 — Freshness and Resolution value objects](docs/architecture/decisions/0008-freshness-and-resolution-types.md)
|
|
323
|
+
- [ADR 0009 — AuditSubscriber split from Hooks::Dispatcher](docs/architecture/decisions/0009-audit-subscriber-split.md)
|
|
324
|
+
|
|
12
325
|
## 0.15.0 — 2026-05-26
|
|
13
326
|
|
|
14
327
|
### Breaking
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC
|
|
|
14
14
|
Two versions, deliberately independent:
|
|
15
15
|
|
|
16
16
|
- **Protocol wire string:** `textus/3`. Breaking changes require `textus/4`.
|
|
17
|
-
- **Gem version:** semver, currently `0.
|
|
17
|
+
- **Gem version:** semver, currently `0.18.0`. Decoupled from the protocol string — internal refactors bump the gem; only wire-format changes bump the protocol.
|
|
18
18
|
|
|
19
19
|
Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
|
|
20
20
|
|
|
@@ -119,18 +119,22 @@ textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectorie
|
|
|
119
119
|
|
|
120
120
|
```ruby
|
|
121
121
|
# Inside .textus/hooks/local_file.rb
|
|
122
|
-
Textus.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
122
|
+
Textus.hook do |reg|
|
|
123
|
+
reg.on(:resolve_intake, :local_file) do |config:, args:, **|
|
|
124
|
+
path = config["path"] or raise "local-file requires intake.config.path"
|
|
125
|
+
{
|
|
126
|
+
_meta: { "last_refreshed_at" => Time.now.utc.iso8601, "source_path" => path },
|
|
127
|
+
body: File.read(File.expand_path(path)),
|
|
128
|
+
}
|
|
129
|
+
end
|
|
128
130
|
end
|
|
129
131
|
```
|
|
130
132
|
|
|
131
133
|
```ruby
|
|
132
|
-
Textus.
|
|
133
|
-
|
|
134
|
+
Textus.hook do |reg|
|
|
135
|
+
reg.on(:transform_rows, :rank_by_recency) do |rows:, **|
|
|
136
|
+
rows.sort_by { |r| r["updated_at"].to_s }.reverse
|
|
137
|
+
end
|
|
134
138
|
end
|
|
135
139
|
```
|
|
136
140
|
|
data/SPEC.md
CHANGED
|
@@ -450,7 +450,7 @@ Row transforms are RPC hooks on the `:transform_rows` event. See §5.10.
|
|
|
450
450
|
|
|
451
451
|
### 5.10 Hooks
|
|
452
452
|
|
|
453
|
-
textus has a single hook registration verb: `Textus.on(event, name, **opts) { ... }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path.
|
|
453
|
+
textus has a single hook registration verb: `Textus.hook { |reg| reg.on(event, name, **opts) { ... } }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path; the store-scoped loader drains the queued blocks and invokes each with its own registry.
|
|
454
454
|
|
|
455
455
|
The subdirectory layout under `hooks/` is organizational only; the registered event and name come from the DSL call, not the file path.
|
|
456
456
|
|
|
@@ -458,14 +458,16 @@ The subdirectory layout under `hooks/` is organizational only; the registered ev
|
|
|
458
458
|
|
|
459
459
|
```ruby
|
|
460
460
|
# Canonical form — works for every event:
|
|
461
|
-
Textus.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
461
|
+
Textus.hook do |reg|
|
|
462
|
+
reg.on(:resolve_intake, :my_source) { |config:, args:, **| … }
|
|
463
|
+
reg.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
|
|
464
|
+
reg.on(:validate, :storage_writable) { |store:| … }
|
|
465
|
+
reg.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| … }
|
|
466
|
+
reg.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
|
|
467
|
+
end
|
|
466
468
|
```
|
|
467
469
|
|
|
468
|
-
`Textus.
|
|
470
|
+
`Textus.hook` is the sole entry point. The block receives the store's `Hooks::Registry`; `reg.on` is the only registration primitive.
|
|
469
471
|
|
|
470
472
|
#### Event table
|
|
471
473
|
|
|
@@ -821,7 +823,7 @@ Textus internals are organized into four layers. The dependency rule is one-way
|
|
|
821
823
|
|
|
822
824
|
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.
|
|
823
825
|
|
|
824
|
-
Plugin authors interact only with the Hook DSL (`Textus.on(:resolve_intake, ...)`, `
|
|
826
|
+
Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:resolve_intake, ...) }`, `reg.on(:entry_refreshed, ...)`, etc.) and the manifest YAML schema. The layering is internal and may evolve.
|
|
825
827
|
|
|
826
828
|
Both read and write paths flow through the application layer:
|
|
827
829
|
|
|
@@ -830,8 +832,9 @@ Both read and write paths flow through the application layer:
|
|
|
830
832
|
- `Application::Context` is the universal request object: it carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. Use cases never thread these as separate kwargs.
|
|
831
833
|
- `Textus::Operations` is the factory CLI verbs (and future MCP server / HTTP shim)
|
|
832
834
|
use to construct Contexts and use cases. `Operations.for(store, role:)` returns
|
|
833
|
-
a
|
|
834
|
-
|
|
835
|
+
a flat facade exposing one method per use case (`#put`, `#get`, `#refresh`, …);
|
|
836
|
+
internal use-case instances are memoized via `||=` and live under
|
|
837
|
+
`lib/textus/application/{reads,writes,refresh}/`.
|
|
835
838
|
|
|
836
839
|
See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
|
|
837
840
|
|
data/docs/conventions.md
CHANGED
|
@@ -111,8 +111,8 @@ There are two read operations, and the difference matters in custom code:
|
|
|
111
111
|
|
|
112
112
|
| Operation | Triggers refresh? | Use for |
|
|
113
113
|
|-----------|-------------------|---------|
|
|
114
|
-
| `ops.
|
|
115
|
-
| `ops.
|
|
114
|
+
| `ops.get` | No — pure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
|
|
115
|
+
| `ops.get_or_refresh` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
|
|
116
116
|
|
|
117
117
|
Build always uses the pure path; injecting refresh into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_refresh` only when you genuinely want side effects on read.
|
|
118
118
|
|
|
@@ -34,6 +34,30 @@ module Textus
|
|
|
34
34
|
store.manifest.permission_for(zone.to_s).allows_read?(role)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
def bus
|
|
38
|
+
@store.bus
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def manifest = @store.manifest
|
|
42
|
+
def schemas = @store.schemas
|
|
43
|
+
def file_store = @store.file_store
|
|
44
|
+
def audit_log = @store.audit_log
|
|
45
|
+
|
|
46
|
+
def authorize_write!(mentry)
|
|
47
|
+
return if can_write?(mentry.zone)
|
|
48
|
+
|
|
49
|
+
writers = @store.manifest.zone_writers(mentry.zone)
|
|
50
|
+
raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def authorize_read!(mentry)
|
|
54
|
+
return if can_read?(mentry.zone)
|
|
55
|
+
|
|
56
|
+
readers = @store.manifest.zone_readers[mentry.zone]
|
|
57
|
+
readers = nil if readers == :all
|
|
58
|
+
raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
|
|
59
|
+
end
|
|
60
|
+
|
|
37
61
|
def with_role(new_role)
|
|
38
62
|
self.class.new(
|
|
39
63
|
store: @store,
|
|
@@ -23,7 +23,9 @@ module Textus
|
|
|
23
23
|
private
|
|
24
24
|
|
|
25
25
|
def resolve_path(key)
|
|
26
|
-
|
|
26
|
+
res = @ctx.store.manifest.resolve(key)
|
|
27
|
+
mentry = res.entry
|
|
28
|
+
path = res.path
|
|
27
29
|
# Nested entries resolve to a file under the entry path; leaf entries
|
|
28
30
|
# already have a fully-resolved path. Either way `path` is what git
|
|
29
31
|
# needs to know about.
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
|
|
16
16
|
def call(prefix: nil, zone: nil)
|
|
17
17
|
rows = []
|
|
18
|
-
@ctx.
|
|
18
|
+
@ctx.manifest.entries.each do |mentry|
|
|
19
19
|
next if prefix && !mentry.key.start_with?(prefix)
|
|
20
20
|
next if zone && mentry.zone != zone
|
|
21
21
|
|
|
@@ -27,7 +27,7 @@ module Textus
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
29
|
def row_for(mentry)
|
|
30
|
-
set = @ctx.
|
|
30
|
+
set = @ctx.manifest.rules_for(mentry.key)
|
|
31
31
|
refresh = set.refresh
|
|
32
32
|
envelope = safe_get(mentry.key)
|
|
33
33
|
last = envelope&.meta&.dig("last_refreshed_at")
|
|
@@ -61,7 +61,16 @@ module Textus
|
|
|
61
61
|
# Returns the raw envelope or nil. Nested entries (mentry.key is a
|
|
62
62
|
# prefix, not a leaf) and missing files both resolve to nil.
|
|
63
63
|
def safe_get(key)
|
|
64
|
-
@ctx.
|
|
64
|
+
res = @ctx.manifest.resolve(key)
|
|
65
|
+
return nil unless @ctx.file_store.exists?(res.path)
|
|
66
|
+
|
|
67
|
+
raw = @ctx.file_store.read(res.path)
|
|
68
|
+
parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
|
|
69
|
+
Envelope.build(
|
|
70
|
+
key: key, mentry: res.entry, path: res.path,
|
|
71
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
72
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
73
|
+
)
|
|
65
74
|
rescue Textus::Error
|
|
66
75
|
nil
|
|
67
76
|
end
|