textus 0.18.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +43 -48
- data/CHANGELOG.md +238 -0
- data/SPEC.md +35 -2
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
- 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 +5 -3
- 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/writes/accept.rb +43 -16
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- 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 +25 -12
- 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 +4 -6
- 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/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 +7 -7
- 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/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -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 +51 -27
- 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 +58 -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 +112 -0
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +46 -2
- data/lib/textus/manifest.rb +24 -101
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +10 -3
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -14
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/cli/verb/key_normalize.rb +0 -19
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/manifest/resolution.rb +0 -5
- 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: 35d3b6540fc043048133df0874e76b36e522d844da783b69a5e8993418157ae4
|
|
4
|
+
data.tar.gz: e0293b87efb32c1edf2d6c0103dcd94289d91031a4be570f8fdd40fb681c1e79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '039b6f44941ea52ac8d956297d9619c4cc8b2346e7d8bced24bc5671506726a44348da66791aa6d032e56dd403353d1b34e9b2fee37eaec05cd6c83d5defc715'
|
|
7
|
+
data.tar.gz: e6e5e8f97f5f9a07a03813d61f9efa2088fb8f1338af89ba1298bf307c6c72e89f1028aa5a67ffdf8f97f2151c0af1273f82ff80751c59c11aa93ef2953323a9
|
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,244 @@ 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.2 — 2026-05-27
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Promotion predicate `accept_authority_signed` now checks the role's *kind*
|
|
16
|
+
via `manifest.role_kind`, so manifests with a renamed authority role (e.g.
|
|
17
|
+
`owner` instead of `human`) pass the promotion gate. The internal class
|
|
18
|
+
`Predicates::HumanAccept` was renamed to `Predicates::AcceptAuthoritySigned`.
|
|
19
|
+
- `textus schema migrate` now writes as the manifest's declared
|
|
20
|
+
`accept_authority` role instead of the literal `"human"`, and raises a
|
|
21
|
+
clear `UsageError` (with a YAML hint) when no `accept_authority` role is
|
|
22
|
+
declared.
|
|
23
|
+
- `textus accept` / `textus reject` no longer claim "only human role can
|
|
24
|
+
accept" when the manifest declares zero `accept_authority` roles — the
|
|
25
|
+
error now says "no role with accept_authority kind is declared in this
|
|
26
|
+
manifest; accept/reject is disabled".
|
|
27
|
+
- `textus build` now resolves the build role from the manifest's declared
|
|
28
|
+
`generator` kind instead of hardcoding `"builder"`, so renamed generator
|
|
29
|
+
roles work correctly.
|
|
30
|
+
- Manifest validator's "exactly one accept_authority" error message now
|
|
31
|
+
matches what the schema actually enforces.
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
- Legacy `human_accept` promotion-predicate alias (string and symbol forms).
|
|
35
|
+
Manifests using `rules[].promotion.requires: [human_accept]` must change
|
|
36
|
+
to `[accept_authority_signed]`. The error on the old form is actionable:
|
|
37
|
+
`unknown promotion predicate: 'human_accept' (known: schema_valid,
|
|
38
|
+
accept_authority_signed)`.
|
|
39
|
+
- `textus key normalize` verb and the underlying
|
|
40
|
+
`Textus::Application::Tools::MigrateKeys` module. Files dropped into nested
|
|
41
|
+
zones with illegal basenames are still reported by `textus doctor` with a
|
|
42
|
+
`key.illegal` finding; fix them by hand. The `--upgrade-manifest` flag and
|
|
43
|
+
its `Textus::Application::Tools::MigrateManifestToKinds` module (one-shot
|
|
44
|
+
0.19→0.20 manifest upgrader) are removed for the same reason — dead weight.
|
|
45
|
+
- The `migrate-keys` audit-log payload string is no longer emitted (no writer
|
|
46
|
+
produces it).
|
|
47
|
+
|
|
48
|
+
### Internal
|
|
49
|
+
- Final cleanup of role-name leaks identified by the 0.20.2 architecture
|
|
50
|
+
audit (follow-on to 0.20.1 role-kinds refactor).
|
|
51
|
+
|
|
52
|
+
## 0.20.1 — 2026-05-27
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- Optional `roles:` block in `manifest.yaml` lets users rename roles without
|
|
56
|
+
breaking engine semantics. Each declared role maps to one of four engine
|
|
57
|
+
kinds: `accept_authority`, `generator`, `proposer`, `runner`. (#72)
|
|
58
|
+
- `Manifest#role_kind`, `Manifest#roles_with_kind`, `Manifest#zone_kinds`
|
|
59
|
+
accessors for engine integrations.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
- `accept` / `reject` now gate on `accept_authority` kind, not the literal
|
|
63
|
+
`"human"` role. Error messages cite the configured role name.
|
|
64
|
+
- `validator` last-writer trust check uses `accept_authority` kind.
|
|
65
|
+
- Entry `in_generator_zone?` / `in_proposal_zone?` query `zone_kinds`.
|
|
66
|
+
- `Intro` derives `write_flows` and `agent_protocol.role_resolution.roles`
|
|
67
|
+
from the manifest's role mapping.
|
|
68
|
+
- Promote DSL predicate `:human_accept` renamed to `:accept_authority_signed`;
|
|
69
|
+
the old symbol still works as an alias.
|
|
70
|
+
- Schema rejects zone writers that reference an undeclared role when `roles:`
|
|
71
|
+
is declared.
|
|
72
|
+
|
|
73
|
+
### Compatibility
|
|
74
|
+
- No wire protocol change (`textus/3`).
|
|
75
|
+
- Existing manifests without a `roles:` block behave identically to 0.20.0.
|
|
76
|
+
|
|
77
|
+
## 0.20.0 — architecture redesign (2026-05-27)
|
|
78
|
+
|
|
79
|
+
**BREAKING (pre-1.0):** Public top-level utility modules removed,
|
|
80
|
+
`Manifest` routing methods extracted into a dedicated resolver,
|
|
81
|
+
`Hooks::Dispatcher`/`Hooks::Registry` collapsed into a single bus, and
|
|
82
|
+
pubsub hook payloads now ship `ctx:` (a `Textus::Hooks::Context`)
|
|
83
|
+
instead of the raw store. External hook files written against the 0.19
|
|
84
|
+
`register(event, name, ...)` API continue to work unchanged; pubsub
|
|
85
|
+
hook bodies must update signatures from `|store:, ...|` to `|ctx:, ...|`
|
|
86
|
+
and use `ctx.put`/`ctx.get`/`ctx.audit`/`ctx.publish_followup` in place
|
|
87
|
+
of direct `store.*` access. RPC events (`transform_rows`, `resolve_intake`,
|
|
88
|
+
`validate`) keep `store:`.
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
- `Textus::Hooks::Context` — narrow handle for user pubsub hooks. Exposes
|
|
92
|
+
`role`, `correlation_id`, `get`, `list`, `deps`, `freshness`, `put`,
|
|
93
|
+
`delete`, `audit`, and `publish_followup`. All writes route back through
|
|
94
|
+
`Operations` so authorization, audit, and validation cannot be bypassed.
|
|
95
|
+
|
|
96
|
+
### Removed
|
|
97
|
+
- `Textus::Dependencies` — use `Operations#deps`, `#rdeps`, `#published`.
|
|
98
|
+
- `Textus::Refresh` — use `Operations#refresh`. The `normalize_action_result`
|
|
99
|
+
helper is now a private class method on `Application::Refresh::Worker`.
|
|
100
|
+
- `Textus::Hooks::Dispatcher` and `Textus::Hooks::Registry` classes.
|
|
101
|
+
|
|
102
|
+
### Changed
|
|
103
|
+
- `Textus::Projection` moved to `Textus::Application::Projection`.
|
|
104
|
+
- `Textus::MigrateKeys` moved to `Textus::Application::Tools::MigrateKeys`.
|
|
105
|
+
- `Manifest#resolve`, `#enumerate`, and `#suggestions_for` removed from
|
|
106
|
+
the public `Manifest` API. Use `manifest.resolver.resolve(key)` etc.
|
|
107
|
+
via the new `Manifest::Resolver`. `Manifest` retains the data accessors
|
|
108
|
+
(`entries`, `zones`, `rules`, `permissions`, `validate_key!`).
|
|
109
|
+
- `Store` constructs one `Hooks::Bus`; `Store#registry` removed (use
|
|
110
|
+
`Store#bus`). `Hooks::Builtin.register_all(bus)` and
|
|
111
|
+
`Hooks::Loader.new(bus:)` now take a Bus instead of a Registry.
|
|
112
|
+
`Operations.for` no longer accepts `registry:`. Use cases
|
|
113
|
+
(`Refresh::Worker`, `Refresh::All`) take `bus:`.
|
|
114
|
+
- All pubsub events declare `ctx:` instead of `store:` in their kwargs
|
|
115
|
+
schema. Every `bus.publish` call site passes `ctx: hook_context`.
|
|
116
|
+
`Operations#hook_context` builds the per-`Operations` `Hooks::Context`.
|
|
117
|
+
- Manifest entries gain a required `kind:` field
|
|
118
|
+
(`leaf | nested | derived | intake`). Run
|
|
119
|
+
`textus key normalize --upgrade-manifest` to add it to existing
|
|
120
|
+
manifests — the inference is deterministic and lossless.
|
|
121
|
+
- Internal: `Manifest::Entry` is now an abstract namespace; concrete
|
|
122
|
+
classes are `Entry::Leaf`, `Entry::Nested`, `Entry::Derived`,
|
|
123
|
+
`Entry::Intake`. The fields `projection`, `generator`, `compute`,
|
|
124
|
+
`intake_handler`, `intake_config` are removed from the entry
|
|
125
|
+
interface; `Entry::Derived` carries a typed `source`
|
|
126
|
+
(`Projection` or `External`) and `Entry::Intake` carries `handler`
|
|
127
|
+
/ `config`. Use-case code dispatches on entry type rather than
|
|
128
|
+
probing optional fields.
|
|
129
|
+
- `Application::Writes::Build` removed. `Application::Writes::Publish` now
|
|
130
|
+
materializes derived entries (template + projection + external runner)
|
|
131
|
+
AND copies leaf/nested entries to their publish targets in a single pass.
|
|
132
|
+
`Operations#build` is gone; use `Operations#publish` — the `textus build`
|
|
133
|
+
CLI verb is unchanged and produces the same
|
|
134
|
+
`{protocol, built, published_leaves}` JSON shape.
|
|
135
|
+
|
|
136
|
+
## 0.19.1 — drop textus/2 migration hint (2026-05-27)
|
|
137
|
+
|
|
138
|
+
**BREAKING (pre-1.0):** Users on gem ≤0.10 (manifest protocol `textus/2`)
|
|
139
|
+
no longer receive a stepping-stone hint pointing at 0.11.x. The manifest
|
|
140
|
+
parser and `textus doctor` now emit the generic "unsupported version"
|
|
141
|
+
error. Users on ≤0.10 should install 0.11.x first (still on RubyGems)
|
|
142
|
+
to run the migrator, then upgrade to 0.19.1+.
|
|
143
|
+
|
|
144
|
+
### Changed
|
|
145
|
+
- `Textus::Manifest` no longer special-cases `textus/2`; `TEXTUS_2_HINT`
|
|
146
|
+
and `version_hint_for` removed.
|
|
147
|
+
- `Doctor::Check::ProtocolVersion` hint/fix text simplified; no longer
|
|
148
|
+
links to the 0.11.x CHANGELOG anchor.
|
|
149
|
+
|
|
150
|
+
### Removed
|
|
151
|
+
- Two redundant manifest specs (the `Manifest.load` duplicate and the
|
|
152
|
+
`textus/2`-specific hint assertion) collapsed into one generic case.
|
|
153
|
+
|
|
154
|
+
## 0.19.0 — 2026-05-27
|
|
155
|
+
|
|
156
|
+
### Breaking
|
|
157
|
+
|
|
158
|
+
- `Application::Context` is now a slim value object (`role`,
|
|
159
|
+
`correlation_id`, `now`, `dry_run`). Migration table:
|
|
160
|
+
|
|
161
|
+
| Was | Now |
|
|
162
|
+
|-----|-----|
|
|
163
|
+
| `Application::Context.new(store:, role:)` | `Operations.for(store, role:)` (common case) or `Application::Context.build(role:)` (pure call state) |
|
|
164
|
+
| `Application::Context.system(store)` | Pass `store` directly to hooks |
|
|
165
|
+
| `ctx.store` / `ctx.manifest` / `ctx.file_store` etc. | Construct use cases with the explicit port kwargs |
|
|
166
|
+
| `ctx.authorize_write!(mentry)` | `Domain::Authorizer.new(manifest:).authorize_write!(mentry, role:)` |
|
|
167
|
+
| `Put.new(ctx:).call(..., suppress_events: true)` | Use `EnvelopeIO#write` directly |
|
|
168
|
+
| `store.role` inside a hook | Read `role:` from the event payload |
|
|
169
|
+
|
|
170
|
+
- `Operations.new(ctx:, manifest:, file_store:, schemas:, audit_log:, bus:, registry:, root:, store:)`
|
|
171
|
+
is the primary constructor. `Operations.for(store, role:)` remains
|
|
172
|
+
a convenience.
|
|
173
|
+
|
|
174
|
+
- `Application::Writes::Put#call` no longer accepts `suppress_events:`.
|
|
175
|
+
|
|
176
|
+
- `Domain::Policy::Predicates::*` moved to `Application::Policy::Predicates::*`.
|
|
177
|
+
`Domain::Policy::Promotion` moved to `Application::Policy::Promotion`.
|
|
178
|
+
`Promotion#evaluate` now takes `entry:, schemas:, manifest:, role:`
|
|
179
|
+
instead of `store:`.
|
|
180
|
+
|
|
181
|
+
- Hooks/intakes/transforms receive the actual `Store` as `store:`
|
|
182
|
+
(previously a Context impersonating one). Event payloads
|
|
183
|
+
(`:entry_put`, `:entry_deleted`, `:entry_renamed`, `:proposal_accepted`,
|
|
184
|
+
`:proposal_rejected`, `:entry_refreshed`, `:refresh_started`,
|
|
185
|
+
`:refresh_failed`, `:refresh_backgrounded`, `:file_published`,
|
|
186
|
+
`:build_completed`) now carry `role:` directly so hooks can observe
|
|
187
|
+
the actor without reaching through the `store:` handle.
|
|
188
|
+
|
|
189
|
+
- `Application::Refresh::All` is a class, not a module function. Callers
|
|
190
|
+
go through `Operations#refresh_all`.
|
|
191
|
+
|
|
192
|
+
### Added
|
|
193
|
+
|
|
194
|
+
- `Domain::Authorizer` — single source of truth for permission checks.
|
|
195
|
+
- `Application::Policy::Promotion` and `Application::Policy::Predicates::*` —
|
|
196
|
+
the policy evaluator and predicates now live with the Application code
|
|
197
|
+
that loads envelope bytes off disk to evaluate them.
|
|
198
|
+
- `Application::Writes::EnvelopeIO#move` — full move pipeline (replaces
|
|
199
|
+
the in-`Mv` file move + audit).
|
|
200
|
+
- `Application::Writes::EnvelopeIO#read_envelope(key)` — internal
|
|
201
|
+
convenience for callers that need to inspect a pre-move envelope.
|
|
202
|
+
|
|
203
|
+
### Internal
|
|
204
|
+
|
|
205
|
+
- `Builder::Pipeline` no longer re-enters `Operations.for(store)`; it
|
|
206
|
+
takes reader/lister callables from the caller.
|
|
207
|
+
- CLI verbs construct `Operations`, never `Context` directly.
|
|
208
|
+
- `Operations` no longer memoizes per-use-case factories; only
|
|
209
|
+
`envelope_io`, `refresh_worker`, and `orchestrator` are shared.
|
|
210
|
+
- Wire format `textus/3` unchanged. Audit-log NDJSON unchanged.
|
|
211
|
+
|
|
212
|
+
### Known follow-ups
|
|
213
|
+
|
|
214
|
+
- CLI verbs `hook run`, `put --fetch`, `hooks list`, and `rule list`
|
|
215
|
+
still reach into `store.manifest` / `store.registry` directly. A
|
|
216
|
+
future release adds `Operations` projections (e.g. `manifest_view`,
|
|
217
|
+
`hooks_view`, `run_intake`) so these verbs route through the
|
|
218
|
+
Operations boundary.
|
|
219
|
+
- `Application::Writes::Delete#call` retains a `suppress_events:` kwarg
|
|
220
|
+
(used internally by `Reject`). A future release either lifts the
|
|
221
|
+
suppression into `EnvelopeIO`-direct usage (matching the `Mv` path)
|
|
222
|
+
or formalizes per-event suppression as part of the public hook API.
|
|
223
|
+
|
|
224
|
+
## 0.18.1 — 2026-05-27
|
|
225
|
+
|
|
226
|
+
### Fixed
|
|
227
|
+
|
|
228
|
+
- `Hooks::Dispatcher` no longer uses `Timeout.timeout`, which can interrupt a
|
|
229
|
+
hook mid-syscall or mid-`ensure` and leave Ruby state corrupted. Each
|
|
230
|
+
subscriber now runs in a worker thread joined with a deadline; on overrun
|
|
231
|
+
the thread is killed and the hook is recorded as `timed_out` (distinct
|
|
232
|
+
from `errored`).
|
|
233
|
+
|
|
234
|
+
### Added
|
|
235
|
+
|
|
236
|
+
- `Hooks::FireReport` — value object returned from `bus.publish`. Lists
|
|
237
|
+
`fired`, `errored`, and `timed_out` subscriber names; exposes `#ok?` and
|
|
238
|
+
`#failures`. Backwards-compatible: callers that ignore the return value
|
|
239
|
+
(the entire current codebase) keep working.
|
|
240
|
+
- `Hooks::Dispatcher#publish` accepts `strict: true`, which re-raises the
|
|
241
|
+
first failure after every subscriber has been attempted. Intended for
|
|
242
|
+
test setups that want loud hooks; default remains `false`.
|
|
243
|
+
|
|
244
|
+
### Internal
|
|
245
|
+
|
|
246
|
+
- No public API surface changes. CLI behavior, `Operations` methods, wire
|
|
247
|
+
format `textus/3`, and the audit-log NDJSON shape are unchanged. Stores
|
|
248
|
+
written by 0.18.0 round-trip through 0.18.1 byte-for-byte.
|
|
249
|
+
|
|
12
250
|
## 0.18.0 — 2026-05-27
|
|
13
251
|
|
|
14
252
|
Port extraction finishes the hexagonal trajectory. `Store::Reader` and
|
data/SPEC.md
CHANGED
|
@@ -229,6 +229,40 @@ Unknown role values are rejected with `invalid_role`.
|
|
|
229
229
|
|
|
230
230
|
Every successful write records the resolved role and a wall-clock timestamp in `.textus/audit.log`, so reviewers can later distinguish a human edit from an agent edit even though both live in the same file.
|
|
231
231
|
|
|
232
|
+
#### 5.1.1 Role kinds (engine semantics)
|
|
233
|
+
|
|
234
|
+
Internally the engine recognizes four **role kinds** — abstract capability
|
|
235
|
+
markers — rather than the four default role names. A manifest may declare a
|
|
236
|
+
`roles:` block to map any role name to a kind:
|
|
237
|
+
|
|
238
|
+
```yaml
|
|
239
|
+
roles:
|
|
240
|
+
- { name: owner, kind: accept_authority }
|
|
241
|
+
- { name: compiler, kind: generator }
|
|
242
|
+
- { name: proposer, kind: proposer }
|
|
243
|
+
- { name: fetcher, kind: runner }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Kind allow-list: `accept_authority`, `generator`, `proposer`, `runner`.
|
|
247
|
+
At most one role may have `accept_authority`. When `roles:` is declared,
|
|
248
|
+
every entry in `zones[*].write_policy` must be a declared role name.
|
|
249
|
+
|
|
250
|
+
When the `roles:` block is omitted, the default mapping applies:
|
|
251
|
+
|
|
252
|
+
| Default name | Kind |
|
|
253
|
+
|---|---|
|
|
254
|
+
| `human` | `accept_authority` |
|
|
255
|
+
| `agent` | `proposer` |
|
|
256
|
+
| `builder` | `generator` |
|
|
257
|
+
| `runner` | `runner` |
|
|
258
|
+
|
|
259
|
+
This means existing manifests continue to work byte-for-byte. Wire protocol
|
|
260
|
+
`textus/3` is unchanged — kinds are an internal-semantics concept and never
|
|
261
|
+
appear on the wire.
|
|
262
|
+
|
|
263
|
+
The promotion DSL predicate `:human_accept` is now `:accept_authority_signed`;
|
|
264
|
+
the old symbol works as an alias for backwards compatibility.
|
|
265
|
+
|
|
232
266
|
### 5.2 Compute layer (derived entries)
|
|
233
267
|
|
|
234
268
|
Derived entries live in a zone whose `write_policy:` list includes `builder` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry declares a `compute:` block with a `kind:` discriminator.
|
|
@@ -399,7 +433,7 @@ Schema (one JSON object per line, no interior whitespace):
|
|
|
399
433
|
{"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
400
434
|
```
|
|
401
435
|
|
|
402
|
-
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `
|
|
436
|
+
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `mv`, ...). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
|
|
403
437
|
|
|
404
438
|
For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
|
|
405
439
|
|
|
@@ -705,7 +739,6 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
705
739
|
| `init` | write | `human` |
|
|
706
740
|
| `schema {show,init,diff,migrate}` | read/write | `human` for writes |
|
|
707
741
|
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
|
|
708
|
-
| `key normalize [--dry-run\|--write]` | write (with `--write`) | `human` |
|
|
709
742
|
| `key uid K` | read | any |
|
|
710
743
|
|
|
711
744
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
@@ -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,33 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
# Promotion predicate: the role driving the promotion must have
|
|
6
|
+
# role_kind == :accept_authority in the active manifest.
|
|
7
|
+
#
|
|
8
|
+
# Accept/Reject already gate on this kind before reaching the
|
|
9
|
+
# promotion policy, so in the default control-flow this predicate
|
|
10
|
+
# trivially passes. It is kept so manifests can express the
|
|
11
|
+
# requirement explicitly in `rules[].promotion.requires`.
|
|
12
|
+
class AcceptAuthoritySigned
|
|
13
|
+
attr_reader :reason
|
|
14
|
+
|
|
15
|
+
def name
|
|
16
|
+
"accept_authority_signed"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(role:, manifest:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
20
|
+
role_str = role&.to_s
|
|
21
|
+
return true if role_str.nil? || role_str.empty?
|
|
22
|
+
|
|
23
|
+
kind = manifest.role_kind(role_str)
|
|
24
|
+
return true if kind == :accept_authority
|
|
25
|
+
|
|
26
|
+
@reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
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") || {}
|