textus 0.20.0 → 0.22.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/CHANGELOG.md +157 -0
- data/README.md +7 -4
- data/SPEC.md +77 -5
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/application/policy/promotion.rb +6 -11
- data/lib/textus/application/reads/audit.rb +40 -15
- data/lib/textus/application/reads/pulse.rb +63 -0
- data/lib/textus/application/reads/validator.rb +3 -1
- data/lib/textus/application/writes/accept.rb +5 -1
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/materializer.rb +1 -1
- data/lib/textus/application/writes/publish.rb +25 -106
- data/lib/textus/application/writes/reject.rb +5 -1
- data/lib/textus/{intro.rb → boot.rb} +71 -25
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/verb/audit.rb +2 -0
- data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +2 -3
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/manifest/entry/base.rb +43 -6
- data/lib/textus/manifest/entry/derived.rb +40 -4
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +9 -51
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/resolver.rb +8 -5
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +63 -5
- data/lib/textus/manifest.rb +31 -1
- data/lib/textus/operations.rb +8 -1
- data/lib/textus/schema/tools.rb +8 -1
- data/lib/textus/store.rb +5 -1
- data/lib/textus/version.rb +1 -1
- metadata +9 -10
- data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
- data/lib/textus/application/tools/migrate_keys.rb +0 -191
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
- data/lib/textus/cli/verb/key_normalize.rb +0 -48
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/manifest/resolution.rb +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38a9a929bb63f94d5a3cdc4708f09ce5c8e0eca28928d5733b8d842d90ece8b8
|
|
4
|
+
data.tar.gz: 26a8aeb22666788cd30e949eb25c7336db1de9ef7f4110aaf700f7abecdec644
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b4ce0da2828f2623f60601cdd0ac8a69e51cc9670c5de80b92e3b41e0f955361154664b8cb0a7de0e838b5403336ff26589698201885b44001a83cfcd2c3f21
|
|
7
|
+
data.tar.gz: 672d948ab0ccdea1470dcb38c87c233ed717a538d4e4902191a32224dac8475fe1269b4569be00e5eb1f40e9c6146f8867d557d6455601c13fc2e0ce2c381cae
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,163 @@ 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.22.0 — 2026-05-28
|
|
13
|
+
|
|
14
|
+
### Changed (internal — no manifest-schema impact)
|
|
15
|
+
- **Entry polymorphism pass.** Behavior-preserving refactor that
|
|
16
|
+
consolidates cross-cutting fields on `Manifest::Entry::Base` and
|
|
17
|
+
replaces case-statement dispatch with polymorphic methods. Adding
|
|
18
|
+
a new entry kind now costs ~1 file edit instead of ~5–10.
|
|
19
|
+
- `publish_to` is now owned by `Base` (was declared four separate
|
|
20
|
+
times across Leaf/Derived/Nested/Intake).
|
|
21
|
+
- `Base` exposes nil-returning stubs for `template`, `inject_boot`,
|
|
22
|
+
`events`, `publish_each`, `index_filename` — validators and
|
|
23
|
+
serializers no longer need `respond_to?` guards.
|
|
24
|
+
- `Publish#call` dispatches via `entry.publish_via(context)` instead
|
|
25
|
+
of a 4-branch case-statement. The byte-identical
|
|
26
|
+
`publish_leaf_entry` / `publish_intake_entry` helpers are gone.
|
|
27
|
+
- Each `Entry` subclass declares a `KIND` constant and a
|
|
28
|
+
`self.from_raw(common, raw)` factory; `Parser` dispatches via
|
|
29
|
+
`Entry::REGISTRY` instead of a closed `case kind`.
|
|
30
|
+
- Dead `Base#kind` method removed.
|
|
31
|
+
|
|
32
|
+
No public API or manifest YAML changes. All existing manifests load
|
|
33
|
+
identically.
|
|
34
|
+
|
|
35
|
+
Remaining `is_a?(Entry::Derived)` callsites in `builder/`, `renderer/`,
|
|
36
|
+
`application/reads/`, and `domain/staleness/` are out of scope for this
|
|
37
|
+
pass — they touch a different polymorphism axis (what data the entry
|
|
38
|
+
contributes to a build) and will be addressed in a follow-up.
|
|
39
|
+
|
|
40
|
+
Known follow-up: `Intake#nested?` still reads `@raw["nested"]` to
|
|
41
|
+
preserve the `kind: intake, nested: true` YAML overlay used by nested
|
|
42
|
+
intake handlers. This dual discriminator (`kind:` + `nested:`) is a
|
|
43
|
+
design tension worth revisiting alongside the broader is_a? cleanup.
|
|
44
|
+
|
|
45
|
+
## 0.21.1 — 2026-05-27
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
- **Intake entries can now act as builder outputs.** Two related gaps closed:
|
|
49
|
+
- `FormatMatrix` validator no longer rejects `kind: intake` entries in
|
|
50
|
+
generator zones for missing a template. Intake bodies come from a
|
|
51
|
+
`:resolve_intake` handler, so the "derived format requires template"
|
|
52
|
+
rule never applied. (Error message widened from "derived #{format}"
|
|
53
|
+
to "#{format} entries in a generator zone require a template".)
|
|
54
|
+
- `Manifest::Entry::Intake` now parses `publish_to:` from YAML (was
|
|
55
|
+
hardcoded to `[]`).
|
|
56
|
+
- `textus publish` / `textus build` now fan out intake bodies to each
|
|
57
|
+
`publish_to` target, mirroring the Leaf fan-out path. Refresh-time
|
|
58
|
+
fan-out is unchanged — bodies still publish on the next publish/build
|
|
59
|
+
run.
|
|
60
|
+
|
|
61
|
+
Closes #80. Lets consumers replace `kind: derived, compute: { kind:
|
|
62
|
+
external }` runner glue with `kind: intake` + `Textus.on(:resolve_intake)`
|
|
63
|
+
hooks for builder-produced outputs.
|
|
64
|
+
|
|
65
|
+
## 0.21.0 — 2026-05-27
|
|
66
|
+
|
|
67
|
+
### BREAKING
|
|
68
|
+
- `textus intro` is removed. Use `textus boot` instead — same envelope, same
|
|
69
|
+
use case, better name (pairs with the new `pulse` verb to form the agent
|
|
70
|
+
lifecycle: `boot` for static contract, `pulse` for dynamic state).
|
|
71
|
+
- The `Textus::Intro` module is now `Textus::Boot`. The manifest entry field
|
|
72
|
+
`inject_intro:` is now `inject_boot:`. Builder template variable
|
|
73
|
+
`{{intro.*}}` is now `{{boot.*}}`. Pre-1.0; no compatibility alias.
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
- **`textus pulse [--since=N]`** — agent heartbeat verb. Returns an envelope
|
|
77
|
+
with `cursor` (current `latest_seq`), `changed` (audit rows since N),
|
|
78
|
+
`stale` (entries past refresh policy), `pending_review` (keys in review
|
|
79
|
+
zone), and `doctor` (ok/warn/fail counts). One round-trip replaces what
|
|
80
|
+
was previously four separate verbs.
|
|
81
|
+
- **`agent_quickstart` block in `textus boot`** — names the read verbs,
|
|
82
|
+
write verbs, writable zones, default propose zone, and current
|
|
83
|
+
`latest_seq` (the starting cursor for `pulse`). Lets an agent boot once
|
|
84
|
+
and immediately know how to talk and where to start polling.
|
|
85
|
+
- **Audit log rotation.** Active `audit.log` rotates to `audit.log.1` when
|
|
86
|
+
it exceeds `audit.max_size` (default 10MB), keeping the last
|
|
87
|
+
`audit.keep` files (default 5). Each rotated file has a sidecar
|
|
88
|
+
`audit.log.N.meta.json` with `min_seq`/`max_seq`/`rotated_at`. Configure
|
|
89
|
+
via the new top-level `audit:` block in `manifest.yaml`.
|
|
90
|
+
- **Monotonic `seq` on every audit row.** Foundation for cursor-based
|
|
91
|
+
queries; `audit --seq-since=N` and `pulse --since=N` both use it.
|
|
92
|
+
- **`Textus::CursorExpired`** error class, raised by `pulse` and
|
|
93
|
+
`audit --seq-since` when the requested seq has rotated off disk. The
|
|
94
|
+
message names the oldest still-available seq and tells the agent to
|
|
95
|
+
re-orient via `textus boot`.
|
|
96
|
+
- `docs/agent-integration.md` — boot → pulse → work loop reference, with
|
|
97
|
+
an example agent loop and cursor-expiry handling.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
- Audit rows now include a `seq` integer field (existing fields unchanged).
|
|
101
|
+
- `textus boot` envelope gains `agent_quickstart` (additive — existing
|
|
102
|
+
consumers unaffected).
|
|
103
|
+
|
|
104
|
+
## 0.20.2 — 2026-05-27
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
- Promotion predicate `accept_authority_signed` now checks the role's *kind*
|
|
108
|
+
via `manifest.role_kind`, so manifests with a renamed authority role (e.g.
|
|
109
|
+
`owner` instead of `human`) pass the promotion gate. The internal class
|
|
110
|
+
`Predicates::HumanAccept` was renamed to `Predicates::AcceptAuthoritySigned`.
|
|
111
|
+
- `textus schema migrate` now writes as the manifest's declared
|
|
112
|
+
`accept_authority` role instead of the literal `"human"`, and raises a
|
|
113
|
+
clear `UsageError` (with a YAML hint) when no `accept_authority` role is
|
|
114
|
+
declared.
|
|
115
|
+
- `textus accept` / `textus reject` no longer claim "only human role can
|
|
116
|
+
accept" when the manifest declares zero `accept_authority` roles — the
|
|
117
|
+
error now says "no role with accept_authority kind is declared in this
|
|
118
|
+
manifest; accept/reject is disabled".
|
|
119
|
+
- `textus build` now resolves the build role from the manifest's declared
|
|
120
|
+
`generator` kind instead of hardcoding `"builder"`, so renamed generator
|
|
121
|
+
roles work correctly.
|
|
122
|
+
- Manifest validator's "exactly one accept_authority" error message now
|
|
123
|
+
matches what the schema actually enforces.
|
|
124
|
+
|
|
125
|
+
### Removed
|
|
126
|
+
- Legacy `human_accept` promotion-predicate alias (string and symbol forms).
|
|
127
|
+
Manifests using `rules[].promotion.requires: [human_accept]` must change
|
|
128
|
+
to `[accept_authority_signed]`. The error on the old form is actionable:
|
|
129
|
+
`unknown promotion predicate: 'human_accept' (known: schema_valid,
|
|
130
|
+
accept_authority_signed)`.
|
|
131
|
+
- `textus key normalize` verb and the underlying
|
|
132
|
+
`Textus::Application::Tools::MigrateKeys` module. Files dropped into nested
|
|
133
|
+
zones with illegal basenames are still reported by `textus doctor` with a
|
|
134
|
+
`key.illegal` finding; fix them by hand. The `--upgrade-manifest` flag and
|
|
135
|
+
its `Textus::Application::Tools::MigrateManifestToKinds` module (one-shot
|
|
136
|
+
0.19→0.20 manifest upgrader) are removed for the same reason — dead weight.
|
|
137
|
+
- The `migrate-keys` audit-log payload string is no longer emitted (no writer
|
|
138
|
+
produces it).
|
|
139
|
+
|
|
140
|
+
### Internal
|
|
141
|
+
- Final cleanup of role-name leaks identified by the 0.20.2 architecture
|
|
142
|
+
audit (follow-on to 0.20.1 role-kinds refactor).
|
|
143
|
+
|
|
144
|
+
## 0.20.1 — 2026-05-27
|
|
145
|
+
|
|
146
|
+
### Added
|
|
147
|
+
- Optional `roles:` block in `manifest.yaml` lets users rename roles without
|
|
148
|
+
breaking engine semantics. Each declared role maps to one of four engine
|
|
149
|
+
kinds: `accept_authority`, `generator`, `proposer`, `runner`. (#72)
|
|
150
|
+
- `Manifest#role_kind`, `Manifest#roles_with_kind`, `Manifest#zone_kinds`
|
|
151
|
+
accessors for engine integrations.
|
|
152
|
+
|
|
153
|
+
### Changed
|
|
154
|
+
- `accept` / `reject` now gate on `accept_authority` kind, not the literal
|
|
155
|
+
`"human"` role. Error messages cite the configured role name.
|
|
156
|
+
- `validator` last-writer trust check uses `accept_authority` kind.
|
|
157
|
+
- Entry `in_generator_zone?` / `in_proposal_zone?` query `zone_kinds`.
|
|
158
|
+
- `Intro` derives `write_flows` and `agent_protocol.role_resolution.roles`
|
|
159
|
+
from the manifest's role mapping.
|
|
160
|
+
- Promote DSL predicate `:human_accept` renamed to `:accept_authority_signed`;
|
|
161
|
+
the old symbol still works as an alias.
|
|
162
|
+
- Schema rejects zone writers that reference an undeclared role when `roles:`
|
|
163
|
+
is declared.
|
|
164
|
+
|
|
165
|
+
### Compatibility
|
|
166
|
+
- No wire protocol change (`textus/3`).
|
|
167
|
+
- Existing manifests without a `roles:` block behave identically to 0.20.0.
|
|
168
|
+
|
|
12
169
|
## 0.20.0 — architecture redesign (2026-05-27)
|
|
13
170
|
|
|
14
171
|
**BREAKING (pre-1.0):** Public top-level utility modules removed,
|
data/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.ruby-lang.org/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
A context store for codebases that humans and AI agents both have to read and write. Dotted keys, schema-validated entries, role-gated writes, byte-copy publish, an audit log of every change. Built so an agent landing in your repo can run one command (`textus
|
|
8
|
+
A context store for codebases that humans and AI agents both have to read and write. Dotted keys, schema-validated entries, role-gated writes, byte-copy publish, an audit log of every change. Built so an agent landing in your repo can run one command (`textus boot`) and know what to read, what to write, and what's off-limits.
|
|
9
9
|
|
|
10
10
|
Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC.md). Implementation notes: [`docs/`](docs/).
|
|
11
11
|
|
|
@@ -83,7 +83,8 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
|
|
|
83
83
|
- **Typed envelopes (v0.14.0).** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
|
|
84
84
|
- **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
|
|
85
85
|
- **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key normalize --dry-run|--write` rewrites existing stores with illegal segments deterministically.
|
|
86
|
-
- **`textus
|
|
86
|
+
- **`textus boot`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table, and an `agent_quickstart` block (read/write verbs, writable zones, propose zone, latest audit seq). The boot signal for any agent — one tool call and it knows your store.
|
|
87
|
+
- **`textus pulse [--since=N]`.** Per-turn heartbeat for agents: changed entries since cursor N, stale keys, pending review proposals, and a doctor summary. Cursor is a monotonic seq stamped on every audit row; rotation keeps the last 5 files (configurable via `audit:` in the manifest) and raises `CursorExpired` when the requested cursor has fallen off disk.
|
|
87
88
|
- **`textus doctor`.** Health check across 9 categories: missing schemas/templates, broken hooks, illegal nested keys, sentinel drift, audit log readability, unowned schema fields, schema violations, and missing manifest files. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
|
|
88
89
|
- **Actionable hints on every error.** `UnknownKey` carries ranked "did you mean" suggestions. `WriteForbidden` names the role that *would* be allowed. `BadFrontmatter` tells you exactly what to rename. Printed to stderr alongside the JSON envelope on stdout.
|
|
89
90
|
- **Compute.** Derived entries declare `compute: { kind: projection, ... }` (declarative rows + template) or `compute: { kind: external, ... }` (build runner produces the file; textus tracks sources for staleness). Inside projection computes, `transform:` names the row-shaping hook.
|
|
@@ -97,7 +98,7 @@ All verbs accept `--output=json` and return the envelope defined in [SPEC §8](S
|
|
|
97
98
|
- Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
|
|
98
99
|
- Zone semantics and the role/`write_policy` mapping live in [SPEC §5](SPEC.md), with a tutorial expansion in [`docs/zones.md`](docs/zones.md).
|
|
99
100
|
|
|
100
|
-
`textus
|
|
101
|
+
`textus boot` prints the same information for the current store: zones, entry families with schemas, registered hooks, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
|
|
101
102
|
|
|
102
103
|
## Compute and publish
|
|
103
104
|
|
|
@@ -150,9 +151,11 @@ See SPEC.md §5.10 for the full hook contract.
|
|
|
150
151
|
|
|
151
152
|
Schemas (`.textus/schemas/<name>.yaml`) declare field shapes, per-field `maintained_by:` ownership, and an `evolution:` block (`added_in`, `deprecated_at`, `migrate_from`). Full contract in SPEC §5.8.
|
|
152
153
|
|
|
154
|
+
See [`docs/agent-integration.md`](docs/agent-integration.md) for the agent boot → pulse loop.
|
|
155
|
+
|
|
153
156
|
## Examples
|
|
154
157
|
|
|
155
|
-
[`examples/claude-plugin/`](examples/claude-plugin/) — a Claude Code plugin (`voice-tools`) whose entire content surface — agents, skills, commands, `CLAUDE.md`, `plugin.json`, `marketplace.json` — is textus-managed. Demonstrates per-entry formats, `publish_each`, intake actions, in-process transforms and hooks, the agent-propose / human-accept loop, and the `
|
|
158
|
+
[`examples/claude-plugin/`](examples/claude-plugin/) — a Claude Code plugin (`voice-tools`) whose entire content surface — agents, skills, commands, `CLAUDE.md`, `plugin.json`, `marketplace.json` — is textus-managed. Demonstrates per-entry formats, `publish_each`, intake actions, in-process transforms and hooks, the agent-propose / human-accept loop, and the `inject_boot:` flag that puts an orientation preamble at the top of `CLAUDE.md`.
|
|
156
159
|
|
|
157
160
|
## Tests
|
|
158
161
|
|
data/SPEC.md
CHANGED
|
@@ -189,7 +189,7 @@ Validation at manifest load: any unknown variable raises `UsageError`; the templ
|
|
|
189
189
|
|
|
190
190
|
A leaf at `working.skills.writing.voice-writer` (authored at `.textus/zones/working/skills/writing/voice-writer.md`) publishes to `skills/voice-writer/SKILL.md`.
|
|
191
191
|
|
|
192
|
-
**`
|
|
192
|
+
**`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When the builder materializes the entry, it merges the `textus boot` envelope (§9) into the projection data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside its projected rows. The flag is rejected at manifest load on (a) non-derived entries or (b) derived entries without a `template:` — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
|
|
193
193
|
|
|
194
194
|
**Lookup rule:** to resolve a key, find the entry with the longest `key:` prefix that matches. If that entry has `nested: true`, the remaining segments map to subdirectories under its `path`. Otherwise the key must equal an entry exactly. The resolved filesystem path is `<.textus root>/zones/<entry.path>[/<remaining>...].md` — implementations MUST prepend `zones/` to the manifest `path:` when constructing the filesystem location.
|
|
195
195
|
|
|
@@ -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.
|
|
@@ -396,10 +430,12 @@ Every successful write appends one compact JSON object (NDJSON) to `.textus/audi
|
|
|
396
430
|
Schema (one JSON object per line, no interior whitespace):
|
|
397
431
|
|
|
398
432
|
```json
|
|
399
|
-
{"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
433
|
+
{"seq":<integer>,"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
400
434
|
```
|
|
401
435
|
|
|
402
|
-
`
|
|
436
|
+
`seq` is a monotonic integer counter, auto-incremented on each append. It is the foundation for cursor-based queries: `textus audit --seq-since=N` returns only rows with `seq > N`, and `textus pulse --since=N` builds its `changed` array from the same cursor. When an agent's cursor falls below the oldest available seq (due to log rotation), the operation raises `CursorExpired`.
|
|
437
|
+
|
|
438
|
+
`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
439
|
|
|
404
440
|
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
441
|
|
|
@@ -695,7 +731,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
695
731
|
| `hook list` | read | any |
|
|
696
732
|
| `hook run NAME` | write | any |
|
|
697
733
|
| `doctor [--check=NAME[,NAME]] [--output=json]` | read | any |
|
|
698
|
-
| `
|
|
734
|
+
| `boot [--output=json]` | read | any |
|
|
735
|
+
| `pulse [--since=N]` | read | any |
|
|
699
736
|
| `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
|
|
700
737
|
| `delete K --if-etag=E --as=R` | write | per zone |
|
|
701
738
|
| `refresh KEY --as=runner` | write | per zone (typically `runner`) |
|
|
@@ -705,9 +742,38 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
705
742
|
| `init` | write | `human` |
|
|
706
743
|
| `schema {show,init,diff,migrate}` | read/write | `human` for writes |
|
|
707
744
|
| `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
745
|
| `key uid K` | read | any |
|
|
710
746
|
|
|
747
|
+
**`textus boot` envelope extras.** In addition to zones, entries, hooks, write flows, and the `cli_verbs` catalog, the boot envelope includes an `agent_quickstart` block synthesized from the manifest's role-kind declarations:
|
|
748
|
+
|
|
749
|
+
```json
|
|
750
|
+
{
|
|
751
|
+
"agent_quickstart": {
|
|
752
|
+
"read_verbs": ["boot", "get", "list", "audit", "pulse", "freshness", "doctor"],
|
|
753
|
+
"write_verbs": ["put KEY --as=<proposer-role> --stdin"],
|
|
754
|
+
"writable_zones": ["review"],
|
|
755
|
+
"propose_zone": "review",
|
|
756
|
+
"latest_seq": 1842
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
762
|
+
|
|
763
|
+
**`textus pulse` output shape:**
|
|
764
|
+
|
|
765
|
+
```json
|
|
766
|
+
{
|
|
767
|
+
"cursor": 1845,
|
|
768
|
+
"changed": [ { "seq": 1843, "key": "working.x", "verb": "put", "role": "human", "ts": "..." } ],
|
|
769
|
+
"stale": [ "output.marketplace" ],
|
|
770
|
+
"pending_review": [ "review.proposal.123" ],
|
|
771
|
+
"doctor": { "ok": true, "warn": 0, "fail": 0 }
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `stale` is sourced from `freshness`. `pending_review` lists all keys in the review zone. `doctor` is an `{ok, warn, fail}` count summary. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
|
|
776
|
+
|
|
711
777
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
712
778
|
|
|
713
779
|
```json
|
|
@@ -765,6 +831,12 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
|
|
|
765
831
|
|
|
766
832
|
The reference Ruby gem follows semver independently and speaks `textus/3`.
|
|
767
833
|
|
|
834
|
+
## 11.1 Agent integration
|
|
835
|
+
|
|
836
|
+
Agents interact with a textus store through two verbs: `boot` (once per session, for orientation) and `pulse` (per turn, for deltas). The `boot` envelope's `agent_quickstart` block gives the agent its starting cursor (`latest_seq`), its writable zones, and its propose zone. The `pulse` verb returns a delta envelope keyed on that cursor. When audit log rotation expires a cursor, `CursorExpired` signals the agent to call `boot` again.
|
|
837
|
+
|
|
838
|
+
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/agent-integration.md`](docs/agent-integration.md).
|
|
839
|
+
|
|
768
840
|
## 12. Conformance fixtures
|
|
769
841
|
|
|
770
842
|
A conformant implementation MUST pass these fixtures (the reference test suite ships a YAML file listing inputs and expected envelopes):
|
|
@@ -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,19 +1,15 @@
|
|
|
1
|
+
require_relative "predicates/schema_valid"
|
|
2
|
+
require_relative "predicates/accept_authority_signed"
|
|
3
|
+
|
|
1
4
|
module Textus
|
|
2
5
|
module Application
|
|
3
6
|
module Policy
|
|
4
|
-
# Promotion evaluates a list of named predicates against a pending-proposal
|
|
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.
|
|
11
7
|
class Promotion
|
|
12
8
|
Result = Struct.new(:ok?, :reasons, keyword_init: true)
|
|
13
9
|
|
|
14
10
|
REGISTRY = {
|
|
15
11
|
"schema_valid" => -> { Predicates::SchemaValid.new },
|
|
16
|
-
"
|
|
12
|
+
"accept_authority_signed" => -> { Predicates::AcceptAuthoritySigned.new },
|
|
17
13
|
}.freeze
|
|
18
14
|
|
|
19
15
|
def self.from_names(names)
|
|
@@ -49,10 +45,9 @@ module Textus
|
|
|
49
45
|
|
|
50
46
|
def invoke(pred, entry:, schemas:, manifest:, role:)
|
|
51
47
|
case pred.name
|
|
52
|
-
when "
|
|
53
|
-
pred.call(role: role, entry: entry)
|
|
48
|
+
when "accept_authority_signed"
|
|
49
|
+
pred.call(role: role, manifest: manifest, entry: entry)
|
|
54
50
|
else
|
|
55
|
-
# Default shape: schema-style predicates that need entry + schemas + manifest.
|
|
56
51
|
pred.call(entry: entry, schemas: schemas, manifest: manifest)
|
|
57
52
|
end
|
|
58
53
|
end
|
|
@@ -8,27 +8,36 @@ 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(manifest:, root:)
|
|
12
|
-
@manifest
|
|
13
|
-
@
|
|
11
|
+
def initialize(manifest:, root:, audit_log: nil)
|
|
12
|
+
@manifest = manifest
|
|
13
|
+
@root = root
|
|
14
|
+
@log_path = File.join(root, "audit.log")
|
|
15
|
+
@audit_log = audit_log
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
# rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
17
|
-
def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, correlation_id: nil, limit: nil)
|
|
18
|
-
|
|
19
|
+
def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil)
|
|
20
|
+
check_cursor_expiry!(seq_since)
|
|
21
|
+
|
|
22
|
+
files = all_log_files
|
|
23
|
+
return [] if files.empty?
|
|
19
24
|
|
|
20
25
|
rows = []
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
files.each do |file|
|
|
27
|
+
File.foreach(file) do |line|
|
|
28
|
+
parsed = parse_row(line.chomp)
|
|
29
|
+
next unless parsed
|
|
30
|
+
next if key && parsed["key"] != key
|
|
31
|
+
next if role && parsed["role"] != role
|
|
32
|
+
next if verb && parsed["verb"] != verb
|
|
33
|
+
next if zone && !key_in_zone?(parsed["key"], zone)
|
|
34
|
+
next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
|
|
35
|
+
next if seq_since && (parsed["seq"].nil? || parsed["seq"] <= seq_since)
|
|
36
|
+
next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
|
|
30
37
|
|
|
31
|
-
|
|
38
|
+
rows << parsed
|
|
39
|
+
break if limit && rows.length >= limit
|
|
40
|
+
end
|
|
32
41
|
break if limit && rows.length >= limit
|
|
33
42
|
end
|
|
34
43
|
rows
|
|
@@ -48,6 +57,22 @@ module Textus
|
|
|
48
57
|
|
|
49
58
|
private
|
|
50
59
|
|
|
60
|
+
def check_cursor_expiry!(seq_since)
|
|
61
|
+
return unless seq_since
|
|
62
|
+
|
|
63
|
+
log = @audit_log || Textus::Infra::AuditLog.new(@root)
|
|
64
|
+
min = log.min_available_seq
|
|
65
|
+
raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def all_log_files
|
|
69
|
+
rotated = Dir.glob(File.join(@root, "audit.log.*"))
|
|
70
|
+
.reject { |p| p.end_with?(".meta.json") }
|
|
71
|
+
.sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
|
|
72
|
+
active = File.exist?(@log_path) ? [@log_path] : []
|
|
73
|
+
rotated + active
|
|
74
|
+
end
|
|
75
|
+
|
|
51
76
|
def parse_row(line)
|
|
52
77
|
return nil if line.empty?
|
|
53
78
|
return nil unless line.start_with?("{")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Reads
|
|
4
|
+
# Aggregator over audit + freshness + review + doctor. One round-trip
|
|
5
|
+
# for an agent's per-turn heartbeat. All component reads are existing
|
|
6
|
+
# APIs; pulse is sugar with a stable envelope shape and a monotonic
|
|
7
|
+
# cursor (seq).
|
|
8
|
+
class Pulse
|
|
9
|
+
def initialize(ctx:, manifest:, file_store:, audit_log:, root:, store:)
|
|
10
|
+
@ctx = ctx
|
|
11
|
+
@manifest = manifest
|
|
12
|
+
@file_store = file_store
|
|
13
|
+
@audit_log = audit_log
|
|
14
|
+
@root = root
|
|
15
|
+
@store = store
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(since: 0)
|
|
19
|
+
changed = audit_changes_since(since)
|
|
20
|
+
{
|
|
21
|
+
"cursor" => @audit_log.latest_seq,
|
|
22
|
+
"changed" => changed,
|
|
23
|
+
"stale" => stale_keys,
|
|
24
|
+
"pending_review" => review_keys,
|
|
25
|
+
"doctor" => doctor_summary,
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def audit_changes_since(seq)
|
|
32
|
+
Reads::Audit.new(manifest: @manifest, root: @root, audit_log: @audit_log)
|
|
33
|
+
.call(seq_since: seq)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stale_keys
|
|
37
|
+
# Freshness rows use symbol keys: { key: "x.y", status: :stale, ... }
|
|
38
|
+
rows = Reads::Freshness.new(ctx: @ctx, manifest: @manifest, file_store: @file_store).call
|
|
39
|
+
rows.select { |r| r[:status] == :stale }.map { |r| r[:key] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def review_keys
|
|
43
|
+
# List constructor takes only manifest:; returns hashes with string keys.
|
|
44
|
+
# Guard: zones is a Hash keyed by name string.
|
|
45
|
+
return [] unless @manifest.zones.key?("review")
|
|
46
|
+
|
|
47
|
+
rows = Reads::List.new(manifest: @manifest).call(zone: "review")
|
|
48
|
+
rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def doctor_summary
|
|
52
|
+
result = Textus::Doctor.run(@store)
|
|
53
|
+
issues = result["issues"] || []
|
|
54
|
+
{
|
|
55
|
+
"ok" => result["ok"],
|
|
56
|
+
"warn" => issues.count { |i| i["level"] == "warning" },
|
|
57
|
+
"fail" => issues.count { |i| i["level"] == "error" },
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -55,9 +55,11 @@ module Textus
|
|
|
55
55
|
last_writer = @audit_log.last_writer_for(key)
|
|
56
56
|
return if last_writer.nil?
|
|
57
57
|
|
|
58
|
+
last_writer_is_authority = @manifest.role_kind(last_writer) == :accept_authority
|
|
59
|
+
|
|
58
60
|
env.meta.each_key do |field|
|
|
59
61
|
owner = schema.maintained_by(field)
|
|
60
|
-
next if owner.nil? || last_writer == owner ||
|
|
62
|
+
next if owner.nil? || last_writer == owner || last_writer_is_authority
|
|
61
63
|
|
|
62
64
|
violations << { "key" => key, "code" => "role_authority",
|
|
63
65
|
"field" => field, "expected" => owner, "last_writer" => last_writer }
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
require_relative "authority_gate"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
module Application
|
|
3
5
|
module Writes
|
|
4
6
|
class Accept
|
|
7
|
+
include AuthorityGate
|
|
8
|
+
|
|
5
9
|
def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
6
10
|
@ctx = ctx
|
|
7
11
|
@manifest = manifest
|
|
@@ -14,7 +18,7 @@ module Textus
|
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
def call(pending_key)
|
|
17
|
-
|
|
21
|
+
assert_accept_authority!("accept")
|
|
18
22
|
|
|
19
23
|
env = Textus::Application::Reads::Get.new(
|
|
20
24
|
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
# Shared gate for write verbs that require the caller to hold the
|
|
5
|
+
# manifest's accept_authority role. Provides one method, expressed
|
|
6
|
+
# as two early-returns rather than a ternary, so each failure mode
|
|
7
|
+
# reads on its own line.
|
|
8
|
+
module AuthorityGate
|
|
9
|
+
def assert_accept_authority!(verb)
|
|
10
|
+
return if @manifest.role_kind(@ctx.role) == :accept_authority
|
|
11
|
+
|
|
12
|
+
authority = @manifest.roles_with_kind(:accept_authority).first
|
|
13
|
+
if authority.nil?
|
|
14
|
+
raise ProposalError.new(
|
|
15
|
+
"no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
raise ProposalError.new(
|
|
20
|
+
"only #{authority} role can #{verb} proposals; got '#{@ctx.role}'",
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -32,7 +32,7 @@ module Textus
|
|
|
32
32
|
transform_resolver: ->(name) { @bus.rpc_callable(:transform_rows, name) },
|
|
33
33
|
template_loader: ->(name) { read_template(name) },
|
|
34
34
|
transform_context: @store,
|
|
35
|
-
|
|
35
|
+
inject_boot: -> { Textus::Boot.run(@store) },
|
|
36
36
|
)
|
|
37
37
|
end
|
|
38
38
|
|