textus 0.18.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 +43 -48
- data/CHANGELOG.md +173 -0
- data/lib/textus/application/context.rb +20 -58
- 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 +9 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +10 -8
- data/lib/textus/application/reads/get.rb +10 -8
- data/lib/textus/application/reads/get_or_refresh.rb +3 -3
- data/lib/textus/application/reads/list.rb +3 -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 +5 -4
- 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 +10 -6
- data/lib/textus/application/reads/validator.rb +2 -2
- data/lib/textus/application/reads/where.rb +3 -3
- data/lib/textus/application/refresh/all.rb +15 -11
- data/lib/textus/application/refresh/orchestrator.rb +9 -8
- data/lib/textus/application/refresh/worker.rb +56 -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 +38 -15
- data/lib/textus/application/writes/delete.rb +13 -10
- data/lib/textus/application/writes/envelope_io.rb +64 -4
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +57 -94
- data/lib/textus/application/writes/publish.rb +132 -26
- data/lib/textus/application/writes/put.rb +15 -14
- data/lib/textus/application/writes/reject.rb +20 -11
- 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/verb/build.rb +2 -5
- 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 +5 -5
- data/lib/textus/cli/verb/key_normalize.rb +32 -3
- data/lib/textus/cli/verb/put.rb +2 -3
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- 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/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/staleness/generator_check.rb +8 -7
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- 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 +3 -3
- data/lib/textus/infra/audit_subscriber.rb +4 -4
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +3 -2
- 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/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +3 -100
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +2 -2
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -11
- data/lib/textus/application/writes/build.rb +0 -78
- 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 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
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
|
@@ -5,90 +5,85 @@
|
|
|
5
5
|
│ CLI verbs: ops = Operations.for(store, role:) │
|
|
6
6
|
│ ops.<name>(...) # flat methods, one per use │
|
|
7
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:) │
|
|
8
13
|
└──────────────────────┬─────────────────────────────────────┘
|
|
9
14
|
│
|
|
10
15
|
┌─ Application ────────▼─────────────────────────────────────┐
|
|
11
|
-
│ Context (
|
|
12
|
-
│
|
|
13
|
-
│
|
|
14
|
-
│ Context.system(store) for infra path) │
|
|
15
|
-
│ Operations (flat facade — memoized use cases) │
|
|
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
55
|
## Read path (`ops.get(key)`)
|
|
52
56
|
|
|
53
57
|
1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
|
|
54
|
-
2. `Operations#get`
|
|
55
|
-
3. `Reads::Get#call(key)`
|
|
56
|
-
4.
|
|
57
|
-
5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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.
|
|
65
64
|
|
|
66
65
|
## Write path (`ops.put(key, ...)`)
|
|
67
66
|
|
|
68
|
-
1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag
|
|
69
|
-
2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@
|
|
70
|
-
3. Delegates
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 `@ctx.bus`, with `store: @ctx.with_role(@ctx.role)`, `key:`, `envelope:`, `correlation_id:`.
|
|
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`.
|
|
71
|
+
|
|
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
76
|
## Refresh path (`ops.refresh(key)`)
|
|
82
77
|
|
|
83
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,179 @@ 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
|
+
|
|
12
185
|
## 0.18.0 — 2026-05-27
|
|
13
186
|
|
|
14
187
|
Port extraction finishes the hexagonal trajectory. `Store::Reader` and
|
|
@@ -2,69 +2,31 @@ require "securerandom"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Application
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def now
|
|
22
|
-
@now ||= @clock.now
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def dry_run?
|
|
26
|
-
@dry_run
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def can_write?(zone)
|
|
30
|
-
store.manifest.permission_for(zone.to_s).allows_write?(role)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def can_read?(zone)
|
|
34
|
-
store.manifest.permission_for(zone.to_s).allows_read?(role)
|
|
35
|
-
end
|
|
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)
|
|
5
|
+
# A Context describes the call: who is acting (role), what request this
|
|
6
|
+
# is part of (correlation_id), what time it is (now), and whether
|
|
7
|
+
# writes should be suppressed (dry_run).
|
|
8
|
+
#
|
|
9
|
+
# Collaborators (manifest, file_store, bus, audit log, authorizer) are
|
|
10
|
+
# never read from Context — use cases declare them as explicit
|
|
11
|
+
# constructor ports, and Operations wires them in from the Store.
|
|
12
|
+
Context = Data.define(:role, :correlation_id, :now, :dry_run) do
|
|
13
|
+
def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
|
|
14
|
+
new(
|
|
15
|
+
role: role.to_s,
|
|
16
|
+
correlation_id: correlation_id || SecureRandom.uuid,
|
|
17
|
+
now: now || Time.now,
|
|
18
|
+
dry_run: dry_run,
|
|
19
|
+
)
|
|
51
20
|
end
|
|
52
21
|
|
|
53
|
-
def
|
|
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
|
|
22
|
+
def dry_run? = dry_run
|
|
60
23
|
|
|
61
24
|
def with_role(new_role)
|
|
62
25
|
self.class.new(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
dry_run: @dry_run,
|
|
26
|
+
role: new_role.to_s,
|
|
27
|
+
correlation_id: correlation_id,
|
|
28
|
+
now: now,
|
|
29
|
+
dry_run: dry_run,
|
|
68
30
|
)
|
|
69
31
|
end
|
|
70
32
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class HumanAccept
|
|
6
|
+
attr_reader :reason
|
|
7
|
+
|
|
8
|
+
def name
|
|
9
|
+
"human_accept"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The role is passed explicitly. In practice, Accept already enforces
|
|
13
|
+
# role == "human" before reaching the promotion gate, so this predicate
|
|
14
|
+
# trivially passes. It documents intent and future-proofs multi-actor
|
|
15
|
+
# accept flows.
|
|
16
|
+
def call(role:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
+
role_str = role&.to_s
|
|
18
|
+
# If we cannot determine the role, trust that Accept has already
|
|
19
|
+
# checked — allow through.
|
|
20
|
+
return true if role_str.nil? || role_str.empty?
|
|
21
|
+
|
|
22
|
+
ok = (role_str == "human")
|
|
23
|
+
@reason = "current role is '#{role_str}', expected 'human'" unless ok
|
|
24
|
+
ok
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Application
|
|
3
3
|
module Policy
|
|
4
4
|
module Predicates
|
|
5
5
|
class SchemaValid
|
|
@@ -9,17 +9,17 @@ module Textus
|
|
|
9
9
|
"schema_valid"
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def call(entry:,
|
|
13
|
-
return true if entry.nil? ||
|
|
12
|
+
def call(entry:, schemas:, manifest:) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
|
13
|
+
return true if entry.nil? || manifest.nil? || schemas.nil?
|
|
14
14
|
|
|
15
15
|
target_key = entry.meta&.dig("proposal", "target_key")
|
|
16
16
|
return true unless target_key
|
|
17
17
|
|
|
18
|
-
mentry =
|
|
18
|
+
mentry = manifest.resolver.resolve(target_key).entry
|
|
19
19
|
schema_ref = mentry&.schema
|
|
20
20
|
return true unless schema_ref
|
|
21
21
|
|
|
22
|
-
schema =
|
|
22
|
+
schema = schemas.fetch_or_nil(schema_ref)
|
|
23
23
|
return true unless schema
|
|
24
24
|
|
|
25
25
|
frontmatter = entry.meta&.dig("frontmatter") || {}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Application
|
|
3
3
|
module Policy
|
|
4
4
|
# Promotion evaluates a list of named predicates against a pending-proposal
|
|
5
5
|
# entry and returns a Result indicating whether all requirements are met.
|
|
6
|
+
#
|
|
7
|
+
# Lives in Application because the predicates it wires up read live state
|
|
8
|
+
# from explicit ports (schemas, manifest, role). The Domain-side rule
|
|
9
|
+
# statement ("this policy requires predicates X and Y") is captured by
|
|
10
|
+
# Textus::Domain::Policy::Promote.
|
|
6
11
|
class Promotion
|
|
7
12
|
Result = Struct.new(:ok?, :reasons, keyword_init: true)
|
|
8
13
|
|
|
@@ -31,14 +36,26 @@ module Textus
|
|
|
31
36
|
@predicates.map(&:name)
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
def evaluate(entry:,
|
|
39
|
+
def evaluate(entry:, schemas:, manifest:, role:)
|
|
35
40
|
reasons = []
|
|
36
41
|
@predicates.each do |pred|
|
|
37
|
-
ok = pred
|
|
42
|
+
ok = invoke(pred, entry: entry, schemas: schemas, manifest: manifest, role: role)
|
|
38
43
|
reasons << "#{pred.name}: #{pred.reason || "predicate failed"}" unless ok
|
|
39
44
|
end
|
|
40
45
|
Result.new(ok?: reasons.empty?, reasons: reasons)
|
|
41
46
|
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def invoke(pred, entry:, schemas:, manifest:, role:)
|
|
51
|
+
case pred.name
|
|
52
|
+
when "human_accept"
|
|
53
|
+
pred.call(role: role, entry: entry)
|
|
54
|
+
else
|
|
55
|
+
# Default shape: schema-style predicates that need entry + schemas + manifest.
|
|
56
|
+
pred.call(entry: entry, schemas: schemas, manifest: manifest)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
42
59
|
end
|
|
43
60
|
end
|
|
44
61
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "timeout"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Application
|
|
6
|
+
class Projection
|
|
7
|
+
MAX_LIMIT = 1000
|
|
8
|
+
REDUCER_TIMEOUT_SECONDS = 2
|
|
9
|
+
|
|
10
|
+
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
11
|
+
# semantics: pure read (`ops.get`) for materialization paths;
|
|
12
|
+
# `ops.get_or_refresh` if you want refresh-on-stale.
|
|
13
|
+
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
14
|
+
# `transform_resolver` — a callable `->(name) { callable_or_raise }`.
|
|
15
|
+
# `transform_context` — `Application::Context` handed to the transform reducer.
|
|
16
|
+
def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
|
|
17
|
+
@reader = reader
|
|
18
|
+
@spec = spec || {}
|
|
19
|
+
@lister = lister
|
|
20
|
+
@transform_resolver = transform_resolver
|
|
21
|
+
@transform_context = transform_context
|
|
22
|
+
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
23
|
+
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
keys = collect_keys
|
|
28
|
+
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
29
|
+
rows = keys.map do |key|
|
|
30
|
+
env = @reader.call(key)
|
|
31
|
+
row = pluck(env.meta, env.body)
|
|
32
|
+
explicit_pluck ? row : row.merge("_key" => key)
|
|
33
|
+
end
|
|
34
|
+
reduced = apply_reducer(rows)
|
|
35
|
+
# Reducers may return either an Array of rows (legacy / templated builds)
|
|
36
|
+
# or a Hash that becomes the structured-format payload base. In the Hash
|
|
37
|
+
# case, downstream sort/limit/position markers don't apply, and the
|
|
38
|
+
# builder owns `_meta.generated_at` so we don't stamp it here.
|
|
39
|
+
return reduced if reduced.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
rows = reduced
|
|
42
|
+
rows = sort(rows)
|
|
43
|
+
rows = rows.first(@limit)
|
|
44
|
+
mark_positions(rows)
|
|
45
|
+
{ "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def apply_reducer(rows)
|
|
51
|
+
name = @spec["transform"] or return rows
|
|
52
|
+
callable = @transform_resolver.call(name)
|
|
53
|
+
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
54
|
+
callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
|
|
55
|
+
end
|
|
56
|
+
rescue Timeout::Error
|
|
57
|
+
raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def collect_keys
|
|
61
|
+
prefixes = Array(@spec["select"])
|
|
62
|
+
prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def pluck(frontmatter, _body)
|
|
66
|
+
fields = @spec["pluck"]
|
|
67
|
+
if fields.nil? || fields == "*"
|
|
68
|
+
frontmatter
|
|
69
|
+
else
|
|
70
|
+
Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Adds `_first`, `_last`, and `_index` markers so templates can emit
|
|
75
|
+
# delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
|
|
76
|
+
def mark_positions(rows)
|
|
77
|
+
last_idx = rows.length - 1
|
|
78
|
+
rows.each_with_index do |row, i|
|
|
79
|
+
row["_index"] = i
|
|
80
|
+
row["_first"] = i.zero?
|
|
81
|
+
row["_last"] = (i == last_idx)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sort(rows)
|
|
86
|
+
sb = @spec["sort_by"] or return rows
|
|
87
|
+
rows.sort_by { |r| r[sb].to_s }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -8,9 +8,9 @@ module Textus
|
|
|
8
8
|
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
9
9
|
# rows produce nil and are skipped).
|
|
10
10
|
class Audit
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
13
|
-
@log_path = File.join(
|
|
11
|
+
def initialize(manifest:, root:)
|
|
12
|
+
@manifest = manifest
|
|
13
|
+
@log_path = File.join(root, "audit.log")
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
@@ -58,7 +58,7 @@ module Textus
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def key_in_zone?(key, zone)
|
|
61
|
-
mentry = @
|
|
61
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
62
62
|
mentry && mentry.zone == zone
|
|
63
63
|
rescue Textus::Error
|
|
64
64
|
false
|