textus 0.15.0 → 0.20.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 +50 -55
- data/CHANGELOG.md +486 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +20 -34
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +11 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +17 -6
- data/lib/textus/application/reads/get.rb +37 -11
- data/lib/textus/application/reads/get_or_refresh.rb +8 -8
- data/lib/textus/application/reads/list.rb +5 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +6 -3
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +12 -3
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +6 -3
- data/lib/textus/application/refresh/all.rb +16 -5
- data/lib/textus/application/refresh/orchestrator.rb +9 -9
- data/lib/textus/application/refresh/worker.rb +59 -32
- data/lib/textus/application/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +36 -13
- data/lib/textus/application/writes/delete.rb +13 -15
- data/lib/textus/application/writes/envelope_io.rb +166 -0
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +56 -95
- data/lib/textus/application/writes/publish.rb +132 -27
- data/lib/textus/application/writes/put.rb +17 -20
- data/lib/textus/application/writes/reject.rb +18 -9
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- 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 -5
- 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 +4 -2
- data/lib/textus/cli/verb/hook_run.rb +6 -4
- data/lib/textus/cli/verb/hooks.rb +8 -5
- 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 +35 -3
- 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 +5 -4
- 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 +4 -2
- 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/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +4 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- 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/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
- data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
- 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/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +7 -7
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +64 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +4 -100
- data/lib/textus/operations.rb +147 -23
- data/lib/textus/schema/tools.rb +7 -7
- 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 +31 -23
- data/lib/textus/application/writes/build.rb +0 -79
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -63
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/hooks/registry.rb +0 -81
- data/lib/textus/migrate_keys.rb +0 -187
- 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/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
- 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: 613e04300389a5f5bf058f4e75c15b5ff19f813fb7ef6102ba38ce53ecd43043
|
|
4
|
+
data.tar.gz: 97d121b40ac753af1e04893429db1abfe4044f791fff5ea02de33910a4cfa00c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13d11e92b35b952fc9974293544ab49d50dc6055f7ee80771b33a82f511ba534f6cf3fa8c26a84e886778f6b5ee662a3a540d36c7072c23f2b366540a365e066
|
|
7
|
+
data.tar.gz: 7cbf43d42e37271b73122d5db10a463a1936c39696cc6a5e7eee56646ccdb1c503a645e63b3b40e09bfc911ae531e2258b02d4e715458bcd54a193d9e7ca5b0d
|
data/ARCHITECTURE.md
CHANGED
|
@@ -3,92 +3,87 @@
|
|
|
3
3
|
```
|
|
4
4
|
┌─ Interface ────────────────────────────────────────────────┐
|
|
5
5
|
│ CLI verbs: ops = Operations.for(store, role:) │
|
|
6
|
-
│ ops
|
|
7
|
-
│
|
|
8
|
-
│
|
|
6
|
+
│ ops.<name>(...) # flat methods, one per use │
|
|
7
|
+
│ # case (put/get/refresh/…) │
|
|
8
|
+
│ │
|
|
9
|
+
│ Or, for embedders bringing their own ports: │
|
|
10
|
+
│ Operations.new(ctx:, manifest:, file_store:, │
|
|
11
|
+
│ schemas:, audit_log:, bus:, │
|
|
12
|
+
│ registry:, root:, store:) │
|
|
9
13
|
└──────────────────────┬─────────────────────────────────────┘
|
|
10
14
|
│
|
|
11
15
|
┌─ Application ────────▼─────────────────────────────────────┐
|
|
12
|
-
│ Context (
|
|
13
|
-
│
|
|
14
|
-
│
|
|
15
|
-
│ Operations (facade with .reads/.writes/.refresh) │
|
|
16
|
+
│ Context (slim Data: role, correlation_id, now, │
|
|
17
|
+
│ dry_run — request state only) │
|
|
18
|
+
│ Operations (flat facade; inline use-case factories) │
|
|
16
19
|
│ │
|
|
17
20
|
│ reads/{get,list,where,uid,schema_envelope,deps,rdeps, │
|
|
18
21
|
│ published,stale,validate_all,freshness,audit, │
|
|
19
|
-
│ blame,policy_explain}.rb
|
|
20
|
-
│ writes/{put,delete,mv,accept,reject,build,publish
|
|
22
|
+
│ blame,policy_explain,get_or_refresh}.rb │
|
|
23
|
+
│ writes/{put,delete,mv,accept,reject,build,publish, │
|
|
24
|
+
│ envelope_io}.rb │
|
|
21
25
|
│ refresh/{worker,orchestrator,all}.rb │
|
|
26
|
+
│ policy/{promotion,predicates/{schema_valid,human_accept}} │
|
|
22
27
|
└──────────┬───────────────────────────────┬─────────────────┘
|
|
23
28
|
│ uses domain │ uses ports
|
|
24
29
|
┌─ Domain ─▼─────────────────────────────────────────────────┐
|
|
30
|
+
│ Authorizer (manifest + role → allow / deny) │
|
|
25
31
|
│ Permission (write/read predicate per zone) │
|
|
26
32
|
│ Freshness::{Policy,Verdict,Evaluator} │
|
|
27
33
|
│ Action Outcome │
|
|
28
|
-
│ Policy::
|
|
34
|
+
│ Policy::{Promote,Refresh,Matcher,HandlerAllowlist} │
|
|
29
35
|
└──────────────────────────────────────────┬─────────────────┘
|
|
30
36
|
│ implements
|
|
31
37
|
┌─ Infrastructure ─────────────────────────▼─────────────────┐
|
|
32
|
-
│ Store (
|
|
33
|
-
│
|
|
34
|
-
│
|
|
35
|
-
│ schema_envelope} │
|
|
36
|
-
│ Writer#{write_envelope_to_disk,delete_envelope_from_ │
|
|
37
|
-
│ disk,existing_uid_for,ensure_uid, │
|
|
38
|
-
│ enforce_name_match!,serialize_for_put} │
|
|
39
|
-
│ AuditLog, Staleness, Validator, Sentinel │
|
|
38
|
+
│ Store (composition root — wires ports) │
|
|
39
|
+
│ Storage::FileStore (bytes-only port: read/write/delete/ │
|
|
40
|
+
│ exists?/etag) │
|
|
40
41
|
│ Manifest (Entry, Rules, Schema, permission_for) │
|
|
41
|
-
│
|
|
42
|
+
│ Schemas (eager-load cache) │
|
|
43
|
+
│ AuditLog │
|
|
44
|
+
│ Hooks::{Registry,Dispatcher,Loader,FireReport} │
|
|
42
45
|
│ Infra::{Publisher,EventBus,Clock,Refresh::Lock, │
|
|
43
|
-
│ Refresh::Detached}
|
|
46
|
+
│ Refresh::Detached,BuildLock,AuditSubscriber} │
|
|
44
47
|
│ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
|
|
45
48
|
└────────────────────────────────────────────────────────────┘
|
|
46
49
|
|
|
47
50
|
Dependency rule: arrows point DOWN. Domain has zero outbound
|
|
48
51
|
imports. Application imports Domain + Infra (via ports).
|
|
52
|
+
Use cases declare their real ports in their constructor.
|
|
49
53
|
```
|
|
50
54
|
|
|
51
|
-
## Read path (`ops.
|
|
55
|
+
## Read path (`ops.get(key)`)
|
|
56
|
+
|
|
57
|
+
1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
|
|
58
|
+
2. `Operations#get` constructs `Application::Reads::Get.new(ctx:, manifest:, file_store:)` and calls it.
|
|
59
|
+
3. `Reads::Get#call(key)` resolves the path through `@manifest`, reads bytes via `@file_store`, parses the envelope.
|
|
60
|
+
4. Looks up the refresh policy via `@manifest.rules_for(key)`. If absent, returns the envelope annotated fresh.
|
|
61
|
+
5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
|
|
62
|
+
|
|
63
|
+
`ops.get_or_refresh(key)` composes `Reads::Get` with `Refresh::Orchestrator` to optionally refresh on stale — same as 0.18.x.
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
2. `Operations::Reads#get` returns an `Application::Reads::Get.new(ctx:, orchestrator:)` instance bound to the request context.
|
|
55
|
-
3. `Reads::Get#call(key)` reads the bare envelope from disk via `@ctx.store.reader.read_raw_envelope(key)`.
|
|
56
|
-
4. Resolves the manifest rules for the key via `@ctx.store.manifest.rules_for(key)` and extracts the `refresh` policy.
|
|
57
|
-
5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
|
|
58
|
-
6. If fresh → annotate envelope (`stale: false`, `refreshing: false`) and return.
|
|
59
|
-
7. Otherwise `policy.decide(verdict) → Action` (data, not behavior).
|
|
60
|
-
8. `Refresh::Orchestrator#execute(action, key:)` interprets the `Action`:
|
|
61
|
-
- `Action::Return` → `Outcome::Skipped`
|
|
62
|
-
- `Action::RefreshSync` → run `Refresh::Worker` inline → `Refreshed | Failed`
|
|
63
|
-
- `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_backgrounded`, fork+detach child, return `Outcome::Detached`
|
|
64
|
-
9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
|
|
65
|
+
## Write path (`ops.put(key, ...)`)
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag:)`.
|
|
68
|
+
2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
|
|
69
|
+
3. Delegates persistence to `EnvelopeIO#write`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
|
|
70
|
+
4. Publishes `:entry_put` via `@bus` with `store: @store`, `key:`, `envelope:`, `role: @ctx.role`, `correlation_id: @ctx.correlation_id`.
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
2. `Writes::Put#call` validates the key, resolves the manifest entry, and checks `@ctx.can_write?(mentry.zone)` — raises `WriteForbidden` if denied.
|
|
70
|
-
3. Delegates raw I/O to `Store::Writer#write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag:)`, which:
|
|
71
|
-
- Resolves the path via `Manifest#resolve`
|
|
72
|
-
- Serializes via `Entry.for_format(...).serialize(...)`
|
|
73
|
-
- Validates against schema if declared
|
|
74
|
-
- Etag-checks if `if_etag:` provided (raises `EtagMismatch` on conflict)
|
|
75
|
-
- Writes to disk via `File.binwrite`
|
|
76
|
-
- Appends the audit row
|
|
77
|
-
4. On success, publishes `:entry_put` via the bus, with `store: @ctx.with_role(@ctx.role)`, `key:`, `envelope:`, `correlation_id:`.
|
|
72
|
+
`Writes::{Delete,Mv,Accept,Reject,Build,Publish}` follow the same shape: explicit ports, `Authorizer` for authz, `EnvelopeIO` for persistence (where applicable), event published with `store: real Store + role:` in payload.
|
|
78
73
|
|
|
79
|
-
|
|
74
|
+
`Writes::Mv` delegates the file-move + audit to `EnvelopeIO#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `EnvelopeIO#write` directly — no `Put` bypass.
|
|
80
75
|
|
|
81
|
-
## Refresh path (`ops.refresh
|
|
76
|
+
## Refresh path (`ops.refresh(key)`)
|
|
82
77
|
|
|
83
|
-
1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh
|
|
78
|
+
1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh(key)`.
|
|
84
79
|
2. `Refresh::Worker#run(key)`:
|
|
85
|
-
- Resolves the manifest entry, looks up the intake handler via
|
|
86
|
-
- Publishes `:refresh_started`
|
|
87
|
-
- Invokes the handler under a 30s
|
|
88
|
-
- On any error: publishes `:refresh_failed`, then re-raises
|
|
89
|
-
- On success:
|
|
90
|
-
3.
|
|
80
|
+
- Resolves the manifest entry, looks up the intake handler via `@registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
|
|
81
|
+
- Publishes `:refresh_started` with `role:` in the payload.
|
|
82
|
+
- Invokes the handler under a 30s thread-join deadline.
|
|
83
|
+
- On any error: publishes `:refresh_failed`, then re-raises.
|
|
84
|
+
- On success: applies `@authorizer.authorize_write!` and persists via `EnvelopeIO#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
|
|
85
|
+
3. `ops.refresh_all(prefix:, zone:)` lists stale entries via `Reads::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
|
|
91
86
|
|
|
92
|
-
##
|
|
87
|
+
## Hook payload contract
|
|
93
88
|
|
|
94
|
-
|
|
89
|
+
Hooks/intakes/transforms receive the actual `Textus::Store` (the composition root) as `store:`. Every write/refresh event payload carries `role:` directly so hook authors observe the actor without reaching through `store:`.
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,492 @@ 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.20.0 — architecture redesign (2026-05-27)
|
|
13
|
+
|
|
14
|
+
**BREAKING (pre-1.0):** Public top-level utility modules removed,
|
|
15
|
+
`Manifest` routing methods extracted into a dedicated resolver,
|
|
16
|
+
`Hooks::Dispatcher`/`Hooks::Registry` collapsed into a single bus, and
|
|
17
|
+
pubsub hook payloads now ship `ctx:` (a `Textus::Hooks::Context`)
|
|
18
|
+
instead of the raw store. External hook files written against the 0.19
|
|
19
|
+
`register(event, name, ...)` API continue to work unchanged; pubsub
|
|
20
|
+
hook bodies must update signatures from `|store:, ...|` to `|ctx:, ...|`
|
|
21
|
+
and use `ctx.put`/`ctx.get`/`ctx.audit`/`ctx.publish_followup` in place
|
|
22
|
+
of direct `store.*` access. RPC events (`transform_rows`, `resolve_intake`,
|
|
23
|
+
`validate`) keep `store:`.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `Textus::Hooks::Context` — narrow handle for user pubsub hooks. Exposes
|
|
27
|
+
`role`, `correlation_id`, `get`, `list`, `deps`, `freshness`, `put`,
|
|
28
|
+
`delete`, `audit`, and `publish_followup`. All writes route back through
|
|
29
|
+
`Operations` so authorization, audit, and validation cannot be bypassed.
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
- `Textus::Dependencies` — use `Operations#deps`, `#rdeps`, `#published`.
|
|
33
|
+
- `Textus::Refresh` — use `Operations#refresh`. The `normalize_action_result`
|
|
34
|
+
helper is now a private class method on `Application::Refresh::Worker`.
|
|
35
|
+
- `Textus::Hooks::Dispatcher` and `Textus::Hooks::Registry` classes.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- `Textus::Projection` moved to `Textus::Application::Projection`.
|
|
39
|
+
- `Textus::MigrateKeys` moved to `Textus::Application::Tools::MigrateKeys`.
|
|
40
|
+
- `Manifest#resolve`, `#enumerate`, and `#suggestions_for` removed from
|
|
41
|
+
the public `Manifest` API. Use `manifest.resolver.resolve(key)` etc.
|
|
42
|
+
via the new `Manifest::Resolver`. `Manifest` retains the data accessors
|
|
43
|
+
(`entries`, `zones`, `rules`, `permissions`, `validate_key!`).
|
|
44
|
+
- `Store` constructs one `Hooks::Bus`; `Store#registry` removed (use
|
|
45
|
+
`Store#bus`). `Hooks::Builtin.register_all(bus)` and
|
|
46
|
+
`Hooks::Loader.new(bus:)` now take a Bus instead of a Registry.
|
|
47
|
+
`Operations.for` no longer accepts `registry:`. Use cases
|
|
48
|
+
(`Refresh::Worker`, `Refresh::All`) take `bus:`.
|
|
49
|
+
- All pubsub events declare `ctx:` instead of `store:` in their kwargs
|
|
50
|
+
schema. Every `bus.publish` call site passes `ctx: hook_context`.
|
|
51
|
+
`Operations#hook_context` builds the per-`Operations` `Hooks::Context`.
|
|
52
|
+
- Manifest entries gain a required `kind:` field
|
|
53
|
+
(`leaf | nested | derived | intake`). Run
|
|
54
|
+
`textus key normalize --upgrade-manifest` to add it to existing
|
|
55
|
+
manifests — the inference is deterministic and lossless.
|
|
56
|
+
- Internal: `Manifest::Entry` is now an abstract namespace; concrete
|
|
57
|
+
classes are `Entry::Leaf`, `Entry::Nested`, `Entry::Derived`,
|
|
58
|
+
`Entry::Intake`. The fields `projection`, `generator`, `compute`,
|
|
59
|
+
`intake_handler`, `intake_config` are removed from the entry
|
|
60
|
+
interface; `Entry::Derived` carries a typed `source`
|
|
61
|
+
(`Projection` or `External`) and `Entry::Intake` carries `handler`
|
|
62
|
+
/ `config`. Use-case code dispatches on entry type rather than
|
|
63
|
+
probing optional fields.
|
|
64
|
+
- `Application::Writes::Build` removed. `Application::Writes::Publish` now
|
|
65
|
+
materializes derived entries (template + projection + external runner)
|
|
66
|
+
AND copies leaf/nested entries to their publish targets in a single pass.
|
|
67
|
+
`Operations#build` is gone; use `Operations#publish` — the `textus build`
|
|
68
|
+
CLI verb is unchanged and produces the same
|
|
69
|
+
`{protocol, built, published_leaves}` JSON shape.
|
|
70
|
+
|
|
71
|
+
## 0.19.1 — drop textus/2 migration hint (2026-05-27)
|
|
72
|
+
|
|
73
|
+
**BREAKING (pre-1.0):** Users on gem ≤0.10 (manifest protocol `textus/2`)
|
|
74
|
+
no longer receive a stepping-stone hint pointing at 0.11.x. The manifest
|
|
75
|
+
parser and `textus doctor` now emit the generic "unsupported version"
|
|
76
|
+
error. Users on ≤0.10 should install 0.11.x first (still on RubyGems)
|
|
77
|
+
to run the migrator, then upgrade to 0.19.1+.
|
|
78
|
+
|
|
79
|
+
### Changed
|
|
80
|
+
- `Textus::Manifest` no longer special-cases `textus/2`; `TEXTUS_2_HINT`
|
|
81
|
+
and `version_hint_for` removed.
|
|
82
|
+
- `Doctor::Check::ProtocolVersion` hint/fix text simplified; no longer
|
|
83
|
+
links to the 0.11.x CHANGELOG anchor.
|
|
84
|
+
|
|
85
|
+
### Removed
|
|
86
|
+
- Two redundant manifest specs (the `Manifest.load` duplicate and the
|
|
87
|
+
`textus/2`-specific hint assertion) collapsed into one generic case.
|
|
88
|
+
|
|
89
|
+
## 0.19.0 — 2026-05-27
|
|
90
|
+
|
|
91
|
+
### Breaking
|
|
92
|
+
|
|
93
|
+
- `Application::Context` is now a slim value object (`role`,
|
|
94
|
+
`correlation_id`, `now`, `dry_run`). Migration table:
|
|
95
|
+
|
|
96
|
+
| Was | Now |
|
|
97
|
+
|-----|-----|
|
|
98
|
+
| `Application::Context.new(store:, role:)` | `Operations.for(store, role:)` (common case) or `Application::Context.build(role:)` (pure call state) |
|
|
99
|
+
| `Application::Context.system(store)` | Pass `store` directly to hooks |
|
|
100
|
+
| `ctx.store` / `ctx.manifest` / `ctx.file_store` etc. | Construct use cases with the explicit port kwargs |
|
|
101
|
+
| `ctx.authorize_write!(mentry)` | `Domain::Authorizer.new(manifest:).authorize_write!(mentry, role:)` |
|
|
102
|
+
| `Put.new(ctx:).call(..., suppress_events: true)` | Use `EnvelopeIO#write` directly |
|
|
103
|
+
| `store.role` inside a hook | Read `role:` from the event payload |
|
|
104
|
+
|
|
105
|
+
- `Operations.new(ctx:, manifest:, file_store:, schemas:, audit_log:, bus:, registry:, root:, store:)`
|
|
106
|
+
is the primary constructor. `Operations.for(store, role:)` remains
|
|
107
|
+
a convenience.
|
|
108
|
+
|
|
109
|
+
- `Application::Writes::Put#call` no longer accepts `suppress_events:`.
|
|
110
|
+
|
|
111
|
+
- `Domain::Policy::Predicates::*` moved to `Application::Policy::Predicates::*`.
|
|
112
|
+
`Domain::Policy::Promotion` moved to `Application::Policy::Promotion`.
|
|
113
|
+
`Promotion#evaluate` now takes `entry:, schemas:, manifest:, role:`
|
|
114
|
+
instead of `store:`.
|
|
115
|
+
|
|
116
|
+
- Hooks/intakes/transforms receive the actual `Store` as `store:`
|
|
117
|
+
(previously a Context impersonating one). Event payloads
|
|
118
|
+
(`:entry_put`, `:entry_deleted`, `:entry_renamed`, `:proposal_accepted`,
|
|
119
|
+
`:proposal_rejected`, `:entry_refreshed`, `:refresh_started`,
|
|
120
|
+
`:refresh_failed`, `:refresh_backgrounded`, `:file_published`,
|
|
121
|
+
`:build_completed`) now carry `role:` directly so hooks can observe
|
|
122
|
+
the actor without reaching through the `store:` handle.
|
|
123
|
+
|
|
124
|
+
- `Application::Refresh::All` is a class, not a module function. Callers
|
|
125
|
+
go through `Operations#refresh_all`.
|
|
126
|
+
|
|
127
|
+
### Added
|
|
128
|
+
|
|
129
|
+
- `Domain::Authorizer` — single source of truth for permission checks.
|
|
130
|
+
- `Application::Policy::Promotion` and `Application::Policy::Predicates::*` —
|
|
131
|
+
the policy evaluator and predicates now live with the Application code
|
|
132
|
+
that loads envelope bytes off disk to evaluate them.
|
|
133
|
+
- `Application::Writes::EnvelopeIO#move` — full move pipeline (replaces
|
|
134
|
+
the in-`Mv` file move + audit).
|
|
135
|
+
- `Application::Writes::EnvelopeIO#read_envelope(key)` — internal
|
|
136
|
+
convenience for callers that need to inspect a pre-move envelope.
|
|
137
|
+
|
|
138
|
+
### Internal
|
|
139
|
+
|
|
140
|
+
- `Builder::Pipeline` no longer re-enters `Operations.for(store)`; it
|
|
141
|
+
takes reader/lister callables from the caller.
|
|
142
|
+
- CLI verbs construct `Operations`, never `Context` directly.
|
|
143
|
+
- `Operations` no longer memoizes per-use-case factories; only
|
|
144
|
+
`envelope_io`, `refresh_worker`, and `orchestrator` are shared.
|
|
145
|
+
- Wire format `textus/3` unchanged. Audit-log NDJSON unchanged.
|
|
146
|
+
|
|
147
|
+
### Known follow-ups
|
|
148
|
+
|
|
149
|
+
- CLI verbs `hook run`, `put --fetch`, `hooks list`, and `rule list`
|
|
150
|
+
still reach into `store.manifest` / `store.registry` directly. A
|
|
151
|
+
future release adds `Operations` projections (e.g. `manifest_view`,
|
|
152
|
+
`hooks_view`, `run_intake`) so these verbs route through the
|
|
153
|
+
Operations boundary.
|
|
154
|
+
- `Application::Writes::Delete#call` retains a `suppress_events:` kwarg
|
|
155
|
+
(used internally by `Reject`). A future release either lifts the
|
|
156
|
+
suppression into `EnvelopeIO`-direct usage (matching the `Mv` path)
|
|
157
|
+
or formalizes per-event suppression as part of the public hook API.
|
|
158
|
+
|
|
159
|
+
## 0.18.1 — 2026-05-27
|
|
160
|
+
|
|
161
|
+
### Fixed
|
|
162
|
+
|
|
163
|
+
- `Hooks::Dispatcher` no longer uses `Timeout.timeout`, which can interrupt a
|
|
164
|
+
hook mid-syscall or mid-`ensure` and leave Ruby state corrupted. Each
|
|
165
|
+
subscriber now runs in a worker thread joined with a deadline; on overrun
|
|
166
|
+
the thread is killed and the hook is recorded as `timed_out` (distinct
|
|
167
|
+
from `errored`).
|
|
168
|
+
|
|
169
|
+
### Added
|
|
170
|
+
|
|
171
|
+
- `Hooks::FireReport` — value object returned from `bus.publish`. Lists
|
|
172
|
+
`fired`, `errored`, and `timed_out` subscriber names; exposes `#ok?` and
|
|
173
|
+
`#failures`. Backwards-compatible: callers that ignore the return value
|
|
174
|
+
(the entire current codebase) keep working.
|
|
175
|
+
- `Hooks::Dispatcher#publish` accepts `strict: true`, which re-raises the
|
|
176
|
+
first failure after every subscriber has been attempted. Intended for
|
|
177
|
+
test setups that want loud hooks; default remains `false`.
|
|
178
|
+
|
|
179
|
+
### Internal
|
|
180
|
+
|
|
181
|
+
- No public API surface changes. CLI behavior, `Operations` methods, wire
|
|
182
|
+
format `textus/3`, and the audit-log NDJSON shape are unchanged. Stores
|
|
183
|
+
written by 0.18.0 round-trip through 0.18.1 byte-for-byte.
|
|
184
|
+
|
|
185
|
+
## 0.18.0 — 2026-05-27
|
|
186
|
+
|
|
187
|
+
Port extraction finishes the hexagonal trajectory. `Store::Reader` and
|
|
188
|
+
`Store::Writer` were disguised application code under an infra
|
|
189
|
+
namespace; this release replaces them with a true I/O port
|
|
190
|
+
(`Infra::Storage::FileStore`, bytes only) and lifts their orchestration
|
|
191
|
+
into `Application::Writes::EnvelopeIO` and the existing
|
|
192
|
+
`Application::Reads::*`. `Store` becomes a composition root: nothing
|
|
193
|
+
else. Wire format (`textus/3`) and audit log NDJSON line format are
|
|
194
|
+
byte-identical to 0.17.0 — every change is gem-side.
|
|
195
|
+
|
|
196
|
+
### Breaking (Ruby API)
|
|
197
|
+
|
|
198
|
+
- **`Store::Reader` and `Store::Writer` are deleted.** Both classes
|
|
199
|
+
were doing application work (serialize, UID inject, name-match,
|
|
200
|
+
schema validate, etag negotiate, audit append, event publish) under
|
|
201
|
+
an infra label. Their methods move to flat `Operations` calls:
|
|
202
|
+
```
|
|
203
|
+
store.reader.get(key) → Textus::Operations#get(key)
|
|
204
|
+
store.reader.read_raw_envelope(key) → Textus::Operations#get(key)
|
|
205
|
+
store.reader.list(prefix:, zone:) → Textus::Operations#list(prefix:, zone:)
|
|
206
|
+
store.reader.where(key) → Textus::Operations#where(key)
|
|
207
|
+
store.reader.uid(key) → Textus::Operations#uid(key)
|
|
208
|
+
store.reader.schema_envelope(key) → Textus::Operations#schema_envelope(key)
|
|
209
|
+
store.reader.published → Textus::Operations#published
|
|
210
|
+
store.reader.stale(...) → Textus::Operations#stale(...)
|
|
211
|
+
store.reader.deps(key) → Textus::Operations#deps(key)
|
|
212
|
+
store.reader.rdeps(key) → Textus::Operations#rdeps(key)
|
|
213
|
+
store.reader.validate_all → Textus::Operations#validate_all
|
|
214
|
+
|
|
215
|
+
store.writer.write_envelope_to_disk → Textus::Operations#put(key, ...)
|
|
216
|
+
store.writer.delete_envelope_from_disk → Textus::Operations#delete(key, ...)
|
|
217
|
+
```
|
|
218
|
+
- **`Store#schema_for(name)` is deleted.** Schemas live on a dedicated
|
|
219
|
+
cache:
|
|
220
|
+
```
|
|
221
|
+
store.schema_for(name) → store.schemas.fetch(name)
|
|
222
|
+
```
|
|
223
|
+
- **Infra/Domain relocations.** Files that were `Store::*` because the
|
|
224
|
+
namespace was a catch-all now live in the layer they belong to:
|
|
225
|
+
```
|
|
226
|
+
Textus::Store::AuditLog → Textus::Infra::AuditLog
|
|
227
|
+
Textus::Store::Sentinel → Textus::Domain::Sentinel
|
|
228
|
+
Textus::Store::Staleness → Textus::Domain::Staleness
|
|
229
|
+
Textus::Store::Validator → Textus::Application::Reads::Validator
|
|
230
|
+
```
|
|
231
|
+
- **Write use-case constructors take `envelope_io:`.**
|
|
232
|
+
`Application::Writes::Put.new(ctx:, envelope_io:)` — same for
|
|
233
|
+
`Delete` and `Mv`. External code that constructed write use cases
|
|
234
|
+
directly adds the kwarg.
|
|
235
|
+
- **Note.** Most embedders construct use cases via
|
|
236
|
+
`Textus::Operations.for(store)`. That constructor still works
|
|
237
|
+
without changes — `Operations#for` wires `envelope_io:` from the
|
|
238
|
+
store. Embedders on the recommended path see no breakage.
|
|
239
|
+
|
|
240
|
+
### Added
|
|
241
|
+
|
|
242
|
+
- **`Textus::Infra::Storage::FileStore`** — pure I/O port. `read`,
|
|
243
|
+
`write`, `delete`, `exists?`, `etag` — bytes in, bytes out. No
|
|
244
|
+
serialization, no schema, no manifest, no events. The seam that
|
|
245
|
+
makes non-file storage backends possible.
|
|
246
|
+
- **`Textus::Schemas`** — eager-loading schema cache. Reads the
|
|
247
|
+
`_schemas/**` zone at boot, exposes `fetch(name)` and `each`.
|
|
248
|
+
Replaces the on-demand `Store#schema_for` lookup.
|
|
249
|
+
- **`Textus::Application::Writes::EnvelopeIO`** — the write pipeline
|
|
250
|
+
collaborator. Serializes the envelope, validates against its
|
|
251
|
+
schema, negotiates etag, writes via `FileStore`, appends to audit,
|
|
252
|
+
publishes the event. The shared orchestration that `Put`,
|
|
253
|
+
`Delete`, and `Mv` previously duplicated through `Store::Writer`.
|
|
254
|
+
|
|
255
|
+
### Internal
|
|
256
|
+
|
|
257
|
+
- **`Store` is a composition root.** Its responsibilities are
|
|
258
|
+
construction and exposure: `manifest`, `schemas`, `file_store`,
|
|
259
|
+
`audit_log`, `bus`, `registry`, `root`. No `reader`, no `writer`,
|
|
260
|
+
no `schema_for`. Hook loading (`load_hooks`) and operations
|
|
261
|
+
exposure (`operations`) remain — both delegate to dedicated
|
|
262
|
+
collaborators.
|
|
263
|
+
- **Read use cases read from `file_store`/`manifest`/`schemas`
|
|
264
|
+
directly.** `Reads::Get`, `Reads::List`, `Reads::Where`,
|
|
265
|
+
`Reads::Stale`, `Reads::Deps`, etc., no longer route through a
|
|
266
|
+
reader facade. The path is `Operations → use case → ports`.
|
|
267
|
+
|
|
268
|
+
### Wire format / audit format
|
|
269
|
+
|
|
270
|
+
Unchanged. `textus/3` envelopes written by 0.17.0 round-trip through
|
|
271
|
+
0.18.0 byte-for-byte; audit log NDJSON lines are bidirectionally
|
|
272
|
+
compatible.
|
|
273
|
+
|
|
274
|
+
### Migrating from 0.17
|
|
275
|
+
|
|
276
|
+
Mechanical for embedders; transparent for CLI users.
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
# Reads
|
|
280
|
+
store.reader.get(key) → ops.get(key)
|
|
281
|
+
store.reader.list(prefix: x) → ops.list(prefix: x)
|
|
282
|
+
store.reader.stale(...) → ops.stale(...)
|
|
283
|
+
# (and the rest of the table above)
|
|
284
|
+
|
|
285
|
+
# Writes — recommended path stays the same
|
|
286
|
+
ops.put(key, body: x) # unchanged
|
|
287
|
+
|
|
288
|
+
# Schemas
|
|
289
|
+
store.schema_for(name) → store.schemas.fetch(name)
|
|
290
|
+
|
|
291
|
+
# Renames
|
|
292
|
+
Textus::Store::AuditLog → Textus::Infra::AuditLog
|
|
293
|
+
Textus::Store::Sentinel → Textus::Domain::Sentinel
|
|
294
|
+
Textus::Store::Staleness → Textus::Domain::Staleness
|
|
295
|
+
Textus::Store::Validator → Textus::Application::Reads::Validator
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## 0.17.0 — 2026-05-27
|
|
299
|
+
|
|
300
|
+
API and policy reshape. The public Ruby surface flattens, authorization
|
|
301
|
+
moves from seven duplicated blocks into one helper on `Application::Context`,
|
|
302
|
+
and the only thread-local in the library is gone. Wire format (`textus/3`)
|
|
303
|
+
and CLI JSON output are byte-identical to 0.16.0. Every change is gem-side.
|
|
304
|
+
|
|
305
|
+
### Breaking (Ruby API)
|
|
306
|
+
|
|
307
|
+
- **`Operations` is flat.** The `Operations#reads`, `Operations#writes`,
|
|
308
|
+
and `Operations#refresh` namespace shells are removed; every use case
|
|
309
|
+
is now a directly-named method on `Operations` itself. Callers that
|
|
310
|
+
typed three levels of indirection plus `.call` switch to a single
|
|
311
|
+
method call:
|
|
312
|
+
```ruby
|
|
313
|
+
ops.writes.put.call(key, body: x) → ops.put(key, body: x)
|
|
314
|
+
ops.reads.get.call(key) → ops.get(key)
|
|
315
|
+
ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
|
|
316
|
+
ops.refresh.worker.call(key) → ops.refresh(key)
|
|
317
|
+
ops.refresh.all.call(prefix:, …) → ops.refresh_all(prefix:, …)
|
|
318
|
+
```
|
|
319
|
+
Internal use-case instances are memoized via `||=`. `Operations#with_role`
|
|
320
|
+
returns a fresh `Operations` with no shared memoization.
|
|
321
|
+
- **`Operations::Reads`, `Operations::Writes`, `Operations::Refresh`** —
|
|
322
|
+
the shell classes — are deleted. External code that named them
|
|
323
|
+
directly (rare) must move to the flat methods on `Operations`.
|
|
324
|
+
- **Top-level `Textus.on(event, name) { ... }` is removed.** Hook files
|
|
325
|
+
now wrap registration in a `Textus.hook` block that receives the
|
|
326
|
+
store's registry:
|
|
327
|
+
```ruby
|
|
328
|
+
# before
|
|
329
|
+
Textus.on(:entry_put, "audit") { |store:, key:, **| ... }
|
|
330
|
+
# after
|
|
331
|
+
Textus.hook do |reg|
|
|
332
|
+
reg.on(:entry_put, "audit") { |store:, key:, **| ... }
|
|
333
|
+
end
|
|
334
|
+
```
|
|
335
|
+
Multiple `reg.on` lines under one `Textus.hook` block is idiomatic.
|
|
336
|
+
- **`Textus.with_registry` is removed.** Tests instantiate
|
|
337
|
+
`Textus::Hooks::Registry.new` and call `reg.on(...)` directly — no
|
|
338
|
+
`around` block, no thread-local cleanup.
|
|
339
|
+
- **`Textus::Hooks::Loader.current_registry` is removed.** It was the
|
|
340
|
+
thread-local read accessor; nothing replaces it because no thread-
|
|
341
|
+
local remains.
|
|
342
|
+
- **Write use-case constructors lose `bus:`.** `Application::Writes::*`
|
|
343
|
+
classes pull the bus from `@ctx.bus` instead of taking it as a kwarg.
|
|
344
|
+
External code that constructed `Writes::Put.new(ctx:, bus:)` directly
|
|
345
|
+
drops the `bus:` argument.
|
|
346
|
+
|
|
347
|
+
### Added
|
|
348
|
+
|
|
349
|
+
- **`Application::Context#authorize_write!(mentry)`** — raises
|
|
350
|
+
`WriteForbidden` (with the zone's writers list in `details`) when the
|
|
351
|
+
bound role lacks write permission. Returns `nil` on success. Replaces
|
|
352
|
+
the seven duplicated `unless can_write? ... raise WriteForbidden`
|
|
353
|
+
blocks across `Writes::{Put,Delete,Mv,Accept,Reject,Build,Publish}`.
|
|
354
|
+
- **`Application::Context#authorize_read!(mentry)`** — mirror of
|
|
355
|
+
`authorize_write!`. Raises a new `ReadForbidden` (code `read_forbidden`,
|
|
356
|
+
exit 1, details: `key`, `zone`, `readers`).
|
|
357
|
+
- **`Application::Context#bus`** — returns `store.bus`. Use cases publish
|
|
358
|
+
events through `@ctx.bus`; the prior `@ctx.store.bus` reach-through is
|
|
359
|
+
no longer used in-tree.
|
|
360
|
+
- **`Textus::ReadForbidden`** error class. Symmetric with `WriteForbidden`.
|
|
361
|
+
- **`Textus.hook(&blk)`** — appends the supplied block to a mutex-
|
|
362
|
+
guarded module-level queue. The store-scoped loader drains and invokes
|
|
363
|
+
each block with its registry.
|
|
364
|
+
- **`Textus.drain_hook_blocks`** — public for tests; returns and clears
|
|
365
|
+
the queued blocks under the same mutex.
|
|
366
|
+
- **`Textus::Hooks::Registry#on`** — already the canonical instance API
|
|
367
|
+
since 0.11; explicitly documented as the registration primitive now
|
|
368
|
+
that the top-level shim is gone.
|
|
369
|
+
|
|
370
|
+
### Internal
|
|
371
|
+
|
|
372
|
+
- **`Application::Writes::Mv`** now authorizes both source and
|
|
373
|
+
destination zones. The prior code authorized only the source; the
|
|
374
|
+
centralized `authorize_write!` made the second call a one-liner and
|
|
375
|
+
the gap obvious.
|
|
376
|
+
- **`Hooks::Builtin.register_all`** takes a `registry:` argument and
|
|
377
|
+
calls `registry.on(...)` directly. No thread-local read.
|
|
378
|
+
- **`Hooks::Loader`** is now a per-store class constructed with
|
|
379
|
+
`registry:`. `#load_dir(path)` walks the directory, `load`s each
|
|
380
|
+
`.rb`, then drains `Textus.drain_hook_blocks` and invokes each with
|
|
381
|
+
the registry. Two threads loading two stores concurrently are safe
|
|
382
|
+
because each `load_dir` drains around its own file walk under the
|
|
383
|
+
module-level mutex.
|
|
384
|
+
- **`Doctor::Check::Hooks`** reads `store.registry` directly; no
|
|
385
|
+
thread-local indirection.
|
|
386
|
+
- **`Store#load_hooks`** is a two-liner: construct a `Loader` with
|
|
387
|
+
`@registry`, call `load_dir` against `.textus/hooks/`.
|
|
388
|
+
- **Reads/refresh paths** use `@ctx.bus` instead of `@ctx.store.bus`.
|
|
389
|
+
Same object; the indirection is gone.
|
|
390
|
+
|
|
391
|
+
### Migrating from 0.16
|
|
392
|
+
|
|
393
|
+
Mechanical, sed-friendly. The CLI shape is unchanged — only embedders
|
|
394
|
+
and hook authors need to do anything.
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
# Operations: flat surface
|
|
398
|
+
ops.writes.put.call(key, body: x) → ops.put(key, body: x)
|
|
399
|
+
ops.reads.get.call(key) → ops.get(key)
|
|
400
|
+
ops.reads.get_or_refresh.call(key) → ops.get_or_refresh(key)
|
|
401
|
+
ops.refresh.worker.call(key) → ops.refresh(key) # via Operations#refresh
|
|
402
|
+
ops.refresh.all.call(...) → ops.refresh_all(...)
|
|
403
|
+
|
|
404
|
+
# Hooks: explicit registration
|
|
405
|
+
Textus.on(:entry_put, "x") { |e| ... }
|
|
406
|
+
→
|
|
407
|
+
Textus.hook do |reg|
|
|
408
|
+
reg.on(:entry_put, "x") { |e| ... }
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Tests: no more thread-local scope
|
|
412
|
+
around { |ex| Textus.with_registry(reg) { ex.run } } # delete
|
|
413
|
+
Textus.on(:resolve_intake, :x) { ... } # → reg.on(:resolve_intake, :x) { ... }
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
If you constructed `Writes::Put` (or any other write use case)
|
|
417
|
+
directly, drop the `bus:` kwarg from the constructor call. If you
|
|
418
|
+
constructed `Hooks::Loader` directly, the new signature is
|
|
419
|
+
`Loader.new(registry:)` and the API is `loader.load_dir(path)`.
|
|
420
|
+
|
|
421
|
+
### ADRs
|
|
422
|
+
|
|
423
|
+
- [ADR 0010 — Flat Operations API](docs/architecture/decisions/0010-flat-operations-api.md)
|
|
424
|
+
- [ADR 0011 — Authorize-bang in Context](docs/architecture/decisions/0011-authorize-bang-in-context.md)
|
|
425
|
+
- [ADR 0012 — Explicit hook registration](docs/architecture/decisions/0012-explicit-hook-registration.md)
|
|
426
|
+
|
|
427
|
+
## 0.16.0 — 2026-05-26
|
|
428
|
+
|
|
429
|
+
Type cleanup and infra glue. Wire format (`textus/3`) and CLI JSON output
|
|
430
|
+
are byte-identical to 0.15.0. Every change is gem-side.
|
|
431
|
+
|
|
432
|
+
### Breaking (Ruby API)
|
|
433
|
+
|
|
434
|
+
- **`Envelope#freshness`** is now a `Textus::Domain::Freshness` value (a
|
|
435
|
+
`Data.define(:stale, :refreshing, :reason, :refresh_error, :checked_at,
|
|
436
|
+
:ttl_remaining_ms)`), not a `Hash`. Field access replaces string-key
|
|
437
|
+
lookup: `env.freshness.stale` (was `env.freshness["stale"]`). The
|
|
438
|
+
field formerly emitted as `"stale_reason"` on the wire is named
|
|
439
|
+
`:reason` on the value object; `Freshness#to_h_for_wire` still emits
|
|
440
|
+
`"stale_reason"`, so JSON output is unchanged. New fields
|
|
441
|
+
(`:checked_at`, `:ttl_remaining_ms`) are gem-side only and not on the
|
|
442
|
+
wire.
|
|
443
|
+
- **`Manifest#resolve(key)`** now returns a `Textus::Manifest::Resolution`
|
|
444
|
+
value (`Data.define(:entry, :path, :remaining)`) instead of an
|
|
445
|
+
`[entry, path, remaining]` tuple. Callers that destructured the array
|
|
446
|
+
must switch to field access: `res = manifest.resolve(key); res.entry`.
|
|
447
|
+
Raises `UnknownKey` on miss (unchanged).
|
|
448
|
+
- **`Textus::Store.mint_uid`** is removed. Use `Textus::Uid.mint`. A
|
|
449
|
+
companion `Textus::Uid.valid?(str)` predicate is added.
|
|
450
|
+
- **`Hooks::Dispatcher.new(audit_log:)`** no longer accepts
|
|
451
|
+
`audit_log:`. The dispatcher is now a pure pub/sub. Hook-error audit
|
|
452
|
+
rows are written by `Textus::Infra::AuditSubscriber`, which `Store`
|
|
453
|
+
attaches at boot. The NDJSON audit line format is unchanged
|
|
454
|
+
byte-for-byte.
|
|
455
|
+
|
|
456
|
+
### Added
|
|
457
|
+
|
|
458
|
+
- `Textus::Domain::Freshness` — typed envelope-annotation value object.
|
|
459
|
+
- `Textus::Manifest::Resolution` — typed key-resolution value object.
|
|
460
|
+
- `Textus::Uid` — `.mint` / `.valid?` for the 16-hex UID format.
|
|
461
|
+
- `Textus::Infra::AuditSubscriber` — attaches to the event bus and
|
|
462
|
+
writes the `verb: "event_error"` audit row when a user hook raises.
|
|
463
|
+
- `CLI::Verb.command_name "X"` and `CLI::Verb.parent_group Group::Y`
|
|
464
|
+
DSL. Adding a new CLI verb is now a single declaration in the verb's
|
|
465
|
+
own file; the top-level `VERBS` table and group subcommand maps are
|
|
466
|
+
auto-derived from descendants. Help-output ordering is alphabetical
|
|
467
|
+
by command name.
|
|
468
|
+
|
|
469
|
+
### Changed
|
|
470
|
+
|
|
471
|
+
- `CLI::Group` no longer exposes the `cli_name` writer — use
|
|
472
|
+
`command_name` (the prior `cli_name` reader is removed).
|
|
473
|
+
- `Application::Reads::Get` and `Reads::GetOrRefresh` construct
|
|
474
|
+
`Freshness` values directly; their public signatures are unchanged.
|
|
475
|
+
|
|
476
|
+
### Deprecated
|
|
477
|
+
|
|
478
|
+
- `Textus::CLI::VERBS` constant. Still resolves (via `const_missing` to
|
|
479
|
+
the auto-derived table) for backward compatibility; will be removed
|
|
480
|
+
in a future minor. Prefer `Textus::CLI.verbs`.
|
|
481
|
+
|
|
482
|
+
### Notes for embedders
|
|
483
|
+
|
|
484
|
+
- Group subcommand error messages now list subcommands alphabetically
|
|
485
|
+
(e.g., `key requires a subcommand: mv, normalize, uid` rather than
|
|
486
|
+
`mv, uid, normalize`).
|
|
487
|
+
- Lifecycle audit appends for `verb: "put"` / `"delete"` / `"rename"`
|
|
488
|
+
still flow through `Store::Writer` and `Application::Writes::Mv`.
|
|
489
|
+
Centralizing those in a lifecycle subscriber is deferred to 0.18
|
|
490
|
+
port-extraction; it requires event payloads to carry
|
|
491
|
+
`etag_before`/`etag_after`, which they don't yet.
|
|
492
|
+
|
|
493
|
+
### ADRs
|
|
494
|
+
|
|
495
|
+
- [ADR 0008 — Freshness and Resolution value objects](docs/architecture/decisions/0008-freshness-and-resolution-types.md)
|
|
496
|
+
- [ADR 0009 — AuditSubscriber split from Hooks::Dispatcher](docs/architecture/decisions/0009-audit-subscriber-split.md)
|
|
497
|
+
|
|
12
498
|
## 0.15.0 — 2026-05-26
|
|
13
499
|
|
|
14
500
|
### Breaking
|