textus 0.29.0 → 0.35.1
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 +2 -235
- data/CHANGELOG.md +169 -0
- data/README.md +85 -64
- data/SPEC.md +366 -201
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +93 -76
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +8 -8
- data/lib/textus/cli.rb +21 -18
- data/lib/textus/container.rb +1 -2
- data/lib/textus/dispatcher.rb +11 -3
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check.rb +8 -5
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +18 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope/io/reader.rb +4 -0
- data/lib/textus/envelope/io/writer.rb +8 -0
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +12 -24
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +24 -18
- data/lib/textus/maintenance/zone_mv.rb +1 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +16 -8
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/policy.rb +62 -19
- data/lib/textus/manifest/rules.rb +25 -14
- data/lib/textus/manifest/schema.rb +78 -38
- data/lib/textus/manifest.rb +6 -5
- data/lib/textus/mcp/server.rb +2 -10
- data/lib/textus/mcp/session.rb +7 -23
- data/lib/textus/mcp/tool_schemas.rb +3 -3
- data/lib/textus/mcp/tools.rb +7 -7
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +8 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/policy_explain.rb +19 -10
- data/lib/textus/read/pulse.rb +5 -4
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role_scope.rb +3 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +15 -17
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/mv.rb +17 -15
- data/lib/textus/write/put.rb +15 -17
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus/write/retention_sweep.rb +55 -0
- metadata +32 -18
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
data/SPEC.md
CHANGED
|
@@ -8,11 +8,75 @@
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## Table of contents
|
|
12
|
+
|
|
13
|
+
- [Conventions](#conventions)
|
|
14
|
+
- [1. What textus is](#1-what-textus-is)
|
|
15
|
+
- [1.1 Vocabulary axes](#11-vocabulary-axes)
|
|
16
|
+
- [1.2 The five layers](#12-the-five-layers)
|
|
17
|
+
- [2. Goals and non-goals](#2-goals-and-non-goals)
|
|
18
|
+
- [3. Storage layout](#3-storage-layout)
|
|
19
|
+
- [3.1 Store location precedence](#31-store-location-precedence)
|
|
20
|
+
- [4. Manifest](#4-manifest)
|
|
21
|
+
- [5. Zones and capability-based write gates](#5-zones-and-capability-based-write-gates)
|
|
22
|
+
- [5.1 Role resolution](#51-role-resolution)
|
|
23
|
+
- [5.1.1 Capabilities](#511-capabilities)
|
|
24
|
+
- [5.2 Compute layer (derived entries)](#52-compute-layer-derived-entries)
|
|
25
|
+
- [5.2.1 Projection compute](#521-projection-compute-kind-projection)
|
|
26
|
+
- [5.2.2 External compute](#522-external-compute-kind-external)
|
|
27
|
+
- [5.3 Publish layer](#53-publish-layer-publish_to)
|
|
28
|
+
- [5.4 Intake](#54-intake-declared-fetched-via-registered-intake-handler)
|
|
29
|
+
- [5.5 Pending / accept workflow](#55-pending--accept-workflow)
|
|
30
|
+
- [5.6 Audit log](#56-audit-log)
|
|
31
|
+
- [5.7 Security bounds](#57-security-bounds)
|
|
32
|
+
- [5.8 Schema evolution](#58-schema-evolution)
|
|
33
|
+
- [5.9 Row transforms](#59-row-transforms)
|
|
34
|
+
- [5.10 Hooks](#510-hooks)
|
|
35
|
+
- [5.11 Rules](#511-rules)
|
|
36
|
+
- [5.12 Storage formats](#512-storage-formats)
|
|
37
|
+
- [6. Schemas](#6-schemas)
|
|
38
|
+
- [7. Entry file format](#7-entry-file-format)
|
|
39
|
+
- [8. Envelope (the wire format)](#8-envelope-the-wire-format)
|
|
40
|
+
- [9. CLI surface](#9-cli-surface)
|
|
41
|
+
- [10. ETag semantics](#10-etag-semantics)
|
|
42
|
+
- [10.1 Errors carry hints](#101-errors-carry-hints)
|
|
43
|
+
- [10.2 `textus doctor`](#102-textus-doctor)
|
|
44
|
+
- [11. Versioning](#11-versioning)
|
|
45
|
+
- [11.1 Agent integration](#111-agent-integration)
|
|
46
|
+
- [12. Conformance fixtures](#12-conformance-fixtures)
|
|
47
|
+
- [13. Why not X?](#13-why-not-x)
|
|
48
|
+
- [13.1 Layered architecture (internal)](#131-layered-architecture-internal)
|
|
49
|
+
- [14. Open questions (v3.x scope)](#14-open-questions-v3x-scope)
|
|
50
|
+
- [15. Implementation checklist](#15-implementation-checklist)
|
|
51
|
+
- [16. Migrating from textus/2](#16-migrating-from-textus2)
|
|
52
|
+
- [16.1 Breaking changes in 0.31.0 (capability-based roles)](#161-breaking-changes-in-0310-capability-based-roles)
|
|
53
|
+
- [16.2 Breaking changes in 0.33.0 (workspace/keep + Setup-1 scaffold)](#162-breaking-changes-in-0330-workspacekeep--setup-1-scaffold)
|
|
54
|
+
- [16.3 Breaking changes in 0.35.0 (proposal target-canon + `author_held`)](#163-breaking-changes-in-0350-proposal-target-canon--author_held)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Conventions
|
|
59
|
+
|
|
60
|
+
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**,
|
|
61
|
+
**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this
|
|
62
|
+
document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
|
|
63
|
+
and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174): with their normative
|
|
64
|
+
meaning **only** when they appear in uppercase. The same words in lowercase
|
|
65
|
+
carry their ordinary English sense and impose no requirement.
|
|
66
|
+
|
|
67
|
+
Requirements are stated against any **conforming implementation** of the
|
|
68
|
+
`textus/3` protocol. The Ruby gem `textus` is the reference implementation, but
|
|
69
|
+
the contract is the protocol defined here — not the gem. Where this document and
|
|
70
|
+
the implementation disagree, this document is the source of truth and the
|
|
71
|
+
implementation is the bug.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
11
75
|
## 1. What textus is
|
|
12
76
|
|
|
13
|
-
A storage convention and JSON wire protocol for humans, agents, and
|
|
77
|
+
A storage convention and JSON wire protocol for humans, agents, and automation to read and write structured project memory **deterministically**. It provides addressable dotted keys, schema validation, capability-based write gates, declarative compute, and copy-based publish targets.
|
|
14
78
|
|
|
15
|
-
The storage lives in a `.textus/` directory at the project root. Each entry is a Markdown file with YAML frontmatter. A manifest binds dotted keys to subtrees and declares
|
|
79
|
+
The storage lives in a `.textus/` directory at the project root. Each entry is a Markdown file with YAML frontmatter. A manifest binds dotted keys to subtrees, declares the capabilities each role holds, and declares each zone's kind — write authority for a zone is derived from the role's capabilities and the zone's kind. Schemas (also YAML) define what frontmatter shape each entry must have. Derived entries are computed from other entries via pure projections and a vendored Mustache template engine, then optionally published to repo-relative paths as byte-for-byte file copies. The CLI surface (`textus get/put/list/where/schema/build/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
|
|
16
80
|
|
|
17
81
|
You **shape your own memory structure** inside `.textus/`. The protocol manages how it's read, written, addressed, validated, gated, computed, and published. The contents are entirely yours.
|
|
18
82
|
|
|
@@ -20,10 +84,10 @@ You **shape your own memory structure** inside `.textus/`. The protocol manages
|
|
|
20
84
|
|
|
21
85
|
textus/3 names its concepts along six axes. Reviewers who internalize these can map any part of the spec to the right category:
|
|
22
86
|
|
|
23
|
-
- **Actor** — who is interacting: `human`, `agent`, `
|
|
24
|
-
- **Place** — where data lives: zones such as `
|
|
87
|
+
- **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `fetch`, `build`).
|
|
88
|
+
- **Place** — where data lives: zones such as `knowledge`, `notebook`, `feeds`, `proposals`, `artifacts`.
|
|
25
89
|
- **Thing** — what is stored: entries, fields, keys.
|
|
26
|
-
- **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `
|
|
90
|
+
- **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `fetch`, `build`, …).
|
|
27
91
|
- **Event** — what gets fired after an operation: hook event names, split into RPC events (`:resolve_intake`, `:transform_rows`, `:validate`) and pub-sub events (`:entry_put`, `:build_completed`, …).
|
|
28
92
|
- **Rule** — constraints declared in the top-level `rules:` array of the manifest.
|
|
29
93
|
|
|
@@ -34,7 +98,7 @@ textus is organized as five composable layers. Each layer has a single responsib
|
|
|
34
98
|
| Layer | Name | Responsibility |
|
|
35
99
|
|---|---|---|
|
|
36
100
|
| L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
|
|
37
|
-
| L2 | **Sources** | Declared external inputs (the `
|
|
101
|
+
| L2 | **Sources** | Declared external inputs (the `feeds` zone in the default scaffold; any `quarantine` zone, writable by a role with `fetch`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
|
|
38
102
|
| L3 | **Compute** | Pure transforms from store entries to derived entries. Projections (select/pluck/sort/limit/format) plus a vendored Mustache template subset. No shell execution. |
|
|
39
103
|
| L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish_to:`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/sentinels/<target-rel-path>.textus-managed.json` records the source, sha256, and `mode: "copy"`. |
|
|
40
104
|
| L5 | **Consumers** | Anything that reads the published files or calls the CLI — editors, LLM tools, MCP servers, CI jobs, dashboards. textus is agnostic about who consumes; the envelope is the contract. |
|
|
@@ -45,7 +109,7 @@ textus is organized as five composable layers. Each layer has a single responsib
|
|
|
45
109
|
- Stable wire format (`textus/3`) any language can speak.
|
|
46
110
|
- Deterministic read/write of structured Markdown via a CLI returning JSON.
|
|
47
111
|
- Schema-validated frontmatter using YAML schemas as data.
|
|
48
|
-
-
|
|
112
|
+
- Capability-based write gates (roles hold capabilities; write authority per zone is derived from the role's capabilities and the zone's kind).
|
|
49
113
|
- Optimistic concurrency via ETags.
|
|
50
114
|
- Pure declarative compute: derived entries computed from projections + Mustache, no shell-out.
|
|
51
115
|
- Publish derived entries to well-known paths as body-only plain files.
|
|
@@ -57,7 +121,7 @@ textus is organized as five composable layers. Each layer has a single responsib
|
|
|
57
121
|
- Not a sync protocol. Single-writer per file, ETag-checked.
|
|
58
122
|
- Not a transport. Spawn the CLI or wrap it in MCP/HTTP downstream.
|
|
59
123
|
- Not a UI. Filesystem + CLI. Viewers ship elsewhere.
|
|
60
|
-
- Not a fetcher. textus declares sources; external
|
|
124
|
+
- Not a fetcher. textus declares sources; external automation invokes actions to materialize them.
|
|
61
125
|
- Not an executor. textus computes pure projections but never spawns shell commands.
|
|
62
126
|
|
|
63
127
|
## 3. Storage layout
|
|
@@ -66,23 +130,23 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
66
130
|
|
|
67
131
|
```
|
|
68
132
|
.textus/
|
|
69
|
-
manifest.yaml # internal: key → subtree mapping +
|
|
133
|
+
manifest.yaml # internal: key → subtree mapping + role/zone declarations
|
|
70
134
|
audit.log # internal, append-only NDJSON log of every successful write
|
|
71
135
|
schemas/ # internal: YAML schema files
|
|
72
136
|
templates/ # internal: Mustache templates referenced by derived entries
|
|
73
137
|
hooks/ # internal: one Ruby file per hook
|
|
74
138
|
sentinels/ # internal: bookkeeping for byte-copied publish targets (see §5.3)
|
|
75
139
|
zones/ # ALL user content lives here
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
140
|
+
knowledge/ # zone: knowledge (kind: canon — author-holders write; knowledge.identity.* is the identity convention)
|
|
141
|
+
notebook/ # zone: notebook (kind: workspace — keep-holders write; agent's own durable lane)
|
|
142
|
+
feeds/ # zone: feeds (kind: quarantine — fetch-holders write)
|
|
143
|
+
proposals/ # zone: proposals (kind: queue — propose-holders write)
|
|
144
|
+
artifacts/ # zone: artifacts (kind: derived — build-holders write)
|
|
81
145
|
```
|
|
82
146
|
|
|
83
147
|
Textus internals (`manifest.yaml`, `audit.log`, `schemas/`, `templates/`, `hooks/`, `sentinels/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
|
|
84
148
|
|
|
85
|
-
Zone directories under `zones/` are conventional; their write semantics are declared
|
|
149
|
+
Zone directories under `zones/` are conventional; their write semantics are derived from the zone's declared `kind:` (and the capabilities roles hold), not the directory name.
|
|
86
150
|
|
|
87
151
|
`.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `delete`, `accept`, and `build`. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
|
|
88
152
|
|
|
@@ -98,53 +162,60 @@ When (1) or (2) names a path that has no `manifest.yaml`, the CLI exits with `io
|
|
|
98
162
|
|
|
99
163
|
## 4. Manifest
|
|
100
164
|
|
|
101
|
-
The manifest declares: (a) which
|
|
165
|
+
The manifest declares: (a) which roles exist and the capabilities each holds, (b) which zones exist and each zone's `kind:`, (c) the key-to-subtree mapping, (d) the schema applied to entries in each subtree, and (e) the owner string recorded in writes. Write authority is **derived** — a role may write a zone iff it holds the capability the zone's kind requires (§5).
|
|
102
166
|
|
|
103
167
|
```yaml
|
|
104
168
|
# .textus/manifest.yaml
|
|
105
169
|
version: textus/3
|
|
106
170
|
|
|
171
|
+
roles:
|
|
172
|
+
- { name: human, can: [author, propose] }
|
|
173
|
+
- { name: agent, can: [propose] }
|
|
174
|
+
- { name: automation, can: [fetch, build] }
|
|
175
|
+
|
|
107
176
|
zones:
|
|
108
|
-
- name:
|
|
109
|
-
|
|
110
|
-
- name:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- name:
|
|
115
|
-
|
|
116
|
-
- name:
|
|
117
|
-
|
|
177
|
+
- name: knowledge
|
|
178
|
+
kind: canon
|
|
179
|
+
- name: notebook
|
|
180
|
+
kind: workspace
|
|
181
|
+
owner: agent # optional, informational — agent's own lane
|
|
182
|
+
desc: "agent's durable working memory; bytes climb to knowledge only via propose→accept"
|
|
183
|
+
- name: feeds
|
|
184
|
+
kind: quarantine
|
|
185
|
+
- name: proposals
|
|
186
|
+
kind: queue
|
|
187
|
+
- name: artifacts
|
|
188
|
+
kind: derived
|
|
118
189
|
|
|
119
190
|
entries:
|
|
120
|
-
- key: identity.self
|
|
121
|
-
path: identity/self.md
|
|
122
|
-
zone:
|
|
191
|
+
- key: knowledge.identity.self
|
|
192
|
+
path: knowledge/identity/self.md
|
|
193
|
+
zone: knowledge
|
|
123
194
|
schema: identity
|
|
124
195
|
|
|
125
|
-
- key:
|
|
126
|
-
path:
|
|
127
|
-
zone:
|
|
196
|
+
- key: knowledge.network.org
|
|
197
|
+
path: knowledge/network/org
|
|
198
|
+
zone: knowledge
|
|
128
199
|
schema: person
|
|
129
200
|
owner: textus:network
|
|
130
201
|
nested: true
|
|
131
202
|
|
|
132
|
-
- key:
|
|
133
|
-
path:
|
|
134
|
-
zone:
|
|
203
|
+
- key: artifacts.catalogs.people
|
|
204
|
+
path: artifacts/catalogs/people.md
|
|
205
|
+
zone: artifacts
|
|
135
206
|
schema: null
|
|
136
207
|
owner: textus:build
|
|
137
208
|
|
|
138
209
|
rules:
|
|
139
|
-
- match:
|
|
140
|
-
|
|
210
|
+
- match: feeds.**
|
|
211
|
+
fetch: { ttl: 6h, on_stale: warn }
|
|
141
212
|
|
|
142
213
|
audit:
|
|
143
214
|
max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
|
|
144
215
|
keep: 5 # rotated files to retain (default: 5)
|
|
145
216
|
```
|
|
146
217
|
|
|
147
|
-
Zone names are conventional —
|
|
218
|
+
Zone names are conventional — write authority comes from each zone's declared `kind:` crossed with the capabilities roles hold (§5); rename zones freely.
|
|
148
219
|
|
|
149
220
|
**Key grammar:** dotted segments matching `/^[a-z0-9][a-z0-9-]*$/`. Segments are joined by `.`. A key has at most 8 segments; each segment is at most 64 characters. Segments MUST NOT contain dots, slashes, uppercase letters, or underscores. Example: `working.projects.acme.dashboard`. Enforcement points: manifest load (rejects illegal `key:` declarations and illegal nested file/directory names), `put` (rejects illegal keys before any write), `enumerate` (filters and warns on illegal filenames).
|
|
150
221
|
|
|
@@ -193,23 +264,49 @@ Validation at manifest load: any unknown variable raises `UsageError`; the templ
|
|
|
193
264
|
|
|
194
265
|
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`.
|
|
195
266
|
|
|
196
|
-
**`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When
|
|
267
|
+
**`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When `textus build` 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.
|
|
197
268
|
|
|
198
269
|
**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.
|
|
199
270
|
|
|
200
|
-
## 5. Zones and
|
|
271
|
+
## 5. Zones and capability-based write gates
|
|
201
272
|
|
|
202
|
-
Each zone declares
|
|
273
|
+
Write authority is **derived**, never declared per-zone. Each zone declares a `kind:`; each zone-kind requires one capability to write to it. A role may write a zone iff its capability set (`role.can`) contains the verb that zone-kind requires. textus gates **writes, not reads**: reads are unrestricted at the protocol layer (the `.textus/` files are on disk). Per-role read-scoping, if needed, is an agent-surface projection, not a manifest field.
|
|
203
274
|
|
|
204
|
-
|
|
205
|
-
|---|---|---|
|
|
206
|
-
| `identity` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
|
|
207
|
-
| `working` | `[human, agent, runner]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
|
|
208
|
-
| `intake` | `[runner]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or agents directly. |
|
|
209
|
-
| `review` | `[agent, human]` | Agent-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
|
|
210
|
-
| `output` | `[builder]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
|
|
275
|
+
The kind→verb mapping is closed:
|
|
211
276
|
|
|
212
|
-
|
|
277
|
+
| Zone `kind` | Required capability | Meaning |
|
|
278
|
+
|---|---|---|
|
|
279
|
+
| `canon` | `author` | Authored truth — only the trust anchor writes directly. |
|
|
280
|
+
| `workspace` | `keep` | Agent's own durable lane — bytes never auto-promote; climb to `canon` only via propose→accept. |
|
|
281
|
+
| `quarantine` | `fetch` | External bytes pending validation. |
|
|
282
|
+
| `queue` | `propose` | Proposals awaiting promotion. |
|
|
283
|
+
| `derived` | `build` | Computed from other zones. |
|
|
284
|
+
|
|
285
|
+
`owner:` on a zone is OPTIONAL, INFORMATIONAL metadata (not enforced in 0.33.0 — owner-scoped enforcement is deferred). `desc:` on a zone is optional; the value surfaces as the `purpose` field in `textus boot` zone rows.
|
|
286
|
+
|
|
287
|
+
Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[fetch, build]`):
|
|
288
|
+
|
|
289
|
+
| Zone | `kind` | Required capability | Writable by (default) | Use case |
|
|
290
|
+
|---|---|---|---|---|
|
|
291
|
+
| `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network. `knowledge.identity.*` is the identity key convention. |
|
|
292
|
+
| `notebook` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
|
|
293
|
+
| `feeds` | `quarantine` | `fetch` | `automation` | Declared external inputs (calendar, feeds, scraped pages). Fetched by external automation; never by humans or agents directly. |
|
|
294
|
+
| `proposals` | `queue` | `propose` | `agent`, `human` | Proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `knowledge`. |
|
|
295
|
+
| `artifacts` | `derived` | `build` | `automation` | Computed outputs (catalogs, indexes, published context). Written via `textus build`. |
|
|
296
|
+
|
|
297
|
+
A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role does not hold the capability the target zone-kind requires, the write returns `write_forbidden` with the message `writing '<key>' (zone '<zone>') needs capability '<verb>'` and a hint naming the roles that hold it (`held by: <roles>`, or `held by: no declared role` when none do).
|
|
298
|
+
|
|
299
|
+
Every zone MUST declare a `kind:` describing its role in the data-flow graph.
|
|
300
|
+
The vocabulary is closed: `canon` (authored truth), `workspace` (agent's own
|
|
301
|
+
durable lane), `quarantine` (external bytes pending validation), `queue`
|
|
302
|
+
(proposals awaiting promotion), `derived` (computed from other zones). A
|
|
303
|
+
manifest MUST declare at most one `queue` zone. Because authority is derived, a
|
|
304
|
+
manifest is rejected at load if it declares a zone whose required verb is held
|
|
305
|
+
by **no** declared role (`derived` ⇒ a role with `build`, `queue` ⇒ `propose`,
|
|
306
|
+
`quarantine` ⇒ `fetch`, `workspace` ⇒ `keep`, `canon` ⇒ `author`). Coordination
|
|
307
|
+
is keyed off the declared kind: a zone is derived only if it declares
|
|
308
|
+
`kind: derived`, and proposals route to the declared `queue` zone — there is no
|
|
309
|
+
name-based fallback. A manifest with a kind-less zone is rejected at load.
|
|
213
310
|
|
|
214
311
|
### 5.1 Role resolution
|
|
215
312
|
|
|
@@ -220,56 +317,72 @@ The effective role for any CLI invocation is resolved in this order; the first m
|
|
|
220
317
|
3. `.textus/role` file (one line, role name) at the project root.
|
|
221
318
|
4. Default: `human`.
|
|
222
319
|
|
|
223
|
-
**Canonical
|
|
320
|
+
**Canonical roles (default scaffold):**
|
|
224
321
|
|
|
225
|
-
|
|
|
226
|
-
|
|
227
|
-
| `human` | Interactive user at a terminal. |
|
|
228
|
-
| `agent` | Long-running AI or LLM process. |
|
|
229
|
-
| `
|
|
230
|
-
| `builder` | Build/derive output (catalogs, indexes). |
|
|
322
|
+
| Role | Capabilities (`can`) | Meaning |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| `human` | `[author, propose]` | Interactive user at a terminal; the single trust anchor. |
|
|
325
|
+
| `agent` | `[propose]` | Long-running AI or LLM process; stages proposals. |
|
|
326
|
+
| `automation` | `[fetch, build]` | Scheduled or one-shot scripts: fetch external sources, build derived outputs. |
|
|
231
327
|
|
|
232
|
-
Unknown role values are rejected with `invalid_role`.
|
|
328
|
+
Roles are declared in the manifest's `roles:` block (§5.1.1); the names above are the default mapping when `roles:` is omitted. Unknown role values are rejected with `invalid_role`.
|
|
233
329
|
|
|
234
330
|
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.
|
|
235
331
|
|
|
236
|
-
#### 5.1.1
|
|
332
|
+
#### 5.1.1 Capabilities
|
|
237
333
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
334
|
+
Roles declare **capabilities** — verbs from a closed five-element set. A
|
|
335
|
+
manifest declares a `roles:` block mapping each role name to the capabilities
|
|
336
|
+
it holds via `can:`:
|
|
241
337
|
|
|
242
338
|
```yaml
|
|
243
339
|
roles:
|
|
244
|
-
- { name: owner,
|
|
245
|
-
- { name:
|
|
246
|
-
- { name:
|
|
247
|
-
- { name:
|
|
340
|
+
- { name: owner, can: [author, propose] }
|
|
341
|
+
- { name: proposer, can: [propose] }
|
|
342
|
+
- { name: fetcher, can: [fetch] }
|
|
343
|
+
- { name: compiler, can: [build] }
|
|
344
|
+
- { name: keeper, can: [keep] }
|
|
248
345
|
```
|
|
249
346
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
347
|
+
Capability allow-list: `propose`, `author`, `keep`, `fetch`, `build`. Each verb is the
|
|
348
|
+
required capability for exactly one zone-kind:
|
|
349
|
+
|
|
350
|
+
| Capability | Authorizes writes to zone-kind |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `author` | `canon` |
|
|
353
|
+
| `keep` | `workspace` |
|
|
354
|
+
| `propose` | `queue` |
|
|
355
|
+
| `fetch` | `quarantine` |
|
|
356
|
+
| `build` | `derived` |
|
|
357
|
+
|
|
358
|
+
`author` is the single **trust anchor**: **at most one role may hold `author`**
|
|
359
|
+
(a manifest declaring two or more is rejected at load). The `accept` and
|
|
360
|
+
`reject` transitions also require the `author` capability — `accept` is a
|
|
361
|
+
transition verb, not a capability. Because write authority is derived, there is
|
|
362
|
+
no `write_policy:` — instead, every declared zone-kind's required verb MUST be
|
|
363
|
+
held by at least one role, or the manifest is rejected at load.
|
|
253
364
|
|
|
254
365
|
When the `roles:` block is omitted, the default mapping applies:
|
|
255
366
|
|
|
256
|
-
| Default name |
|
|
367
|
+
| Default name | Capabilities (`can`) |
|
|
257
368
|
|---|---|
|
|
258
|
-
| `human`
|
|
259
|
-
| `agent`
|
|
260
|
-
| `
|
|
261
|
-
| `runner` | `runner` |
|
|
369
|
+
| `human` | `[author, propose]` |
|
|
370
|
+
| `agent` | `[propose, keep]` |
|
|
371
|
+
| `automation` | `[fetch, build]` |
|
|
262
372
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
appear on the wire.
|
|
373
|
+
Wire protocol `textus/3` is unchanged — capabilities are a manifest/semantics
|
|
374
|
+
concept and never appear on the wire.
|
|
266
375
|
|
|
267
|
-
|
|
268
|
-
|
|
376
|
+
Every write transition is authorized by **one Guard** (ADR 0031): an ordered
|
|
377
|
+
list of predicates over a single evaluation context. Predicate #0 of every write
|
|
378
|
+
guard is `zone_writable_by` (the capability gate above); the `author_held`
|
|
379
|
+
predicate keys on the `author` capability and is named `author_held` (it passes
|
|
380
|
+
when the acting role holds `author`). See §5.11 for composing extra predicates via
|
|
381
|
+
`rules[].guard:`.
|
|
269
382
|
|
|
270
383
|
### 5.2 Compute layer (derived entries)
|
|
271
384
|
|
|
272
|
-
Derived entries live in a zone
|
|
385
|
+
Derived entries live in a `derived` zone (writable by a role holding `build`; `automation` by default) — `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.
|
|
273
386
|
|
|
274
387
|
#### 5.2.1 Projection compute (`kind: projection`)
|
|
275
388
|
|
|
@@ -304,7 +417,7 @@ No partials. No lambdas. No HTML escaping (output is raw text, intended for Mark
|
|
|
304
417
|
|
|
305
418
|
#### 5.2.2 External compute (`kind: external`)
|
|
306
419
|
|
|
307
|
-
A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `compute: { kind: external, ... }`. textus does **not** execute the command (consistent with §2); the external
|
|
420
|
+
A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `compute: { kind: external, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `textus freshness` can compare source mtimes against the derived file's `_meta.generated.at` and report staleness.
|
|
308
421
|
|
|
309
422
|
```yaml
|
|
310
423
|
- key: output.catalogs.skills
|
|
@@ -313,7 +426,7 @@ A derived entry that is produced by a build tool *outside* textus — `rake`, `j
|
|
|
313
426
|
owner: build:catalog-skills
|
|
314
427
|
compute:
|
|
315
428
|
kind: external
|
|
316
|
-
command: "rake catalog:skills" # informational;
|
|
429
|
+
command: "rake catalog:skills" # informational; external automation invokes it
|
|
317
430
|
sources: # dotted keys OR repo-relative paths
|
|
318
431
|
- working.projects
|
|
319
432
|
- working.network
|
|
@@ -321,14 +434,14 @@ A derived entry that is produced by a build tool *outside* textus — `rake`, `j
|
|
|
321
434
|
|
|
322
435
|
**`sources:`** is a list. Each element is either a dotted key prefix (matched against manifest entries) or a filesystem path (relative to the repo root, or absolute). For each key prefix, every matching entry's file mtime is checked. For each path, file or directory mtime is checked.
|
|
323
436
|
|
|
324
|
-
**`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `textus freshness` output can carry a hint about how to
|
|
437
|
+
**`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `textus freshness` output can carry a hint about how to fetch.
|
|
325
438
|
|
|
326
439
|
**Freshness contract.** An entry with `compute: { kind: external }` is reported by `textus freshness` as `stale` when:
|
|
327
440
|
- The derived file does not exist, OR
|
|
328
441
|
- `_meta.generated.at` is missing or unparseable, OR
|
|
329
442
|
- Any `sources:` element has been modified after `_meta.generated.at`.
|
|
330
443
|
|
|
331
|
-
**Frontmatter contract.** The external
|
|
444
|
+
**Frontmatter contract.** The external automation is responsible for writing the `generated:` frontmatter block when it produces the file:
|
|
332
445
|
|
|
333
446
|
```yaml
|
|
334
447
|
generated:
|
|
@@ -339,7 +452,7 @@ generated:
|
|
|
339
452
|
|
|
340
453
|
`generated.from` SHOULD match `compute.sources` — they're the same list, recorded twice so a diff proves what was actually consumed.
|
|
341
454
|
|
|
342
|
-
`kind: external` and `kind: projection` are alternatives — exactly one per entry. Templates are not required for `kind: external`: the
|
|
455
|
+
`kind: external` and `kind: projection` are alternatives — exactly one per entry. Templates are not required for `kind: external`: the external automation produces the bytes directly.
|
|
343
456
|
|
|
344
457
|
### 5.3 Publish layer (`publish_to:`)
|
|
345
458
|
|
|
@@ -357,21 +470,21 @@ A sentinel is written for each published file at `<store_root>/sentinels/<target
|
|
|
357
470
|
|
|
358
471
|
**Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is byte-copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The build envelope grows a `published_leaves` array — one row per leaf, with `key`, `source`, and `target` — alongside the existing `built` array. Targets that would resolve outside the repo root are refused.
|
|
359
472
|
|
|
360
|
-
### 5.4 Intake (declared,
|
|
473
|
+
### 5.4 Intake (declared, fetched via registered intake handler)
|
|
361
474
|
|
|
362
|
-
Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when explicitly invoked by `textus
|
|
475
|
+
Intake entries declare an external source by naming an **intake handler** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an intake handler only runs when explicitly invoked by `textus fetch KEY --as=automation` (or by `textus fetch stale`). The declaration is data only:
|
|
363
476
|
|
|
364
477
|
```yaml
|
|
365
|
-
- key:
|
|
366
|
-
zone:
|
|
478
|
+
- key: feeds.calendar.events
|
|
479
|
+
zone: feeds
|
|
367
480
|
intake:
|
|
368
481
|
handler: ical-events
|
|
369
482
|
config:
|
|
370
483
|
url: "https://calendar.google.com/.../basic.ics"
|
|
371
484
|
|
|
372
485
|
rules:
|
|
373
|
-
- match:
|
|
374
|
-
|
|
486
|
+
- match: feeds.calendar.**
|
|
487
|
+
fetch:
|
|
375
488
|
ttl: 6h
|
|
376
489
|
on_stale: warn # warn | sync | timed_sync (default: warn)
|
|
377
490
|
sync_budget_ms: 500 # only used when on_stale: timed_sync (default: 500)
|
|
@@ -385,9 +498,9 @@ rules:
|
|
|
385
498
|
|
|
386
499
|
| Value | Behaviour |
|
|
387
500
|
|---|---|
|
|
388
|
-
| `warn` (default) | Return the entry immediately with `stale: true`, `stale_reason:` populated, and `
|
|
389
|
-
| `sync` | Block the `get` call, run the intake handler in-process, write the
|
|
390
|
-
| `timed_sync` | Like `sync`, but with a `sync_budget_ms` deadline (default 500 ms). If the handler finishes within the budget the fresh envelope is returned. If it does not finish in time, return the stale envelope (with `stale: true`, `
|
|
501
|
+
| `warn` (default) | Return the entry immediately with `stale: true`, `stale_reason:` populated, and `fetching: false`. No blocking. |
|
|
502
|
+
| `sync` | Block the `get` call, run the intake handler in-process, write the fetched result, then return the fresh envelope. The caller waits. |
|
|
503
|
+
| `timed_sync` | Like `sync`, but with a `sync_budget_ms` deadline (default 500 ms). If the handler finishes within the budget the fresh envelope is returned. If it does not finish in time, return the stale envelope (with `stale: true`, `fetching: true`) and let the fetch complete in the background. Fires `:fetch_backgrounded` when the deadline is exceeded. |
|
|
391
504
|
|
|
392
505
|
> **Note:** `list`/`where` paths do **not** annotate freshness — only `get` does.
|
|
393
506
|
|
|
@@ -399,16 +512,16 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
|
|
|
399
512
|
|
|
400
513
|
**Built-in intake handlers.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured `_meta`/body. Built-ins do not perform I/O themselves — the caller (or an outer hook) is responsible for supplying bytes.
|
|
401
514
|
|
|
402
|
-
**
|
|
515
|
+
**Fetch paths.** Two are supported:
|
|
403
516
|
|
|
404
|
-
1. **In-process** — `textus
|
|
405
|
-
2. **External
|
|
517
|
+
1. **In-process** — `textus fetch KEY --as=automation` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under a role holding `fetch` (`automation` by default).
|
|
518
|
+
2. **External automation** — a cron job or agent harness reads `textus list --zone=intake --stale --output=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=automation --stdin`. The CLI verb `textus fetch stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
|
|
406
519
|
|
|
407
|
-
Both paths share the same
|
|
520
|
+
Both paths share the same write gate, audit-log entry, and `:entry_fetched` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
|
|
408
521
|
|
|
409
522
|
### 5.5 Pending / accept workflow
|
|
410
523
|
|
|
411
|
-
Proposal entries are full patches authored into
|
|
524
|
+
Proposal entries are full patches authored into the `proposals` queue zone (writable by `propose`-holders: `agent` and `human` by default) — `proposals` in the default scaffold (Setup-1) — typically by agents. The entry's frontmatter describes the patch it proposes against another zone:
|
|
412
525
|
|
|
413
526
|
```yaml
|
|
414
527
|
---
|
|
@@ -423,9 +536,9 @@ frontmatter:
|
|
|
423
536
|
Proposed body content.
|
|
424
537
|
```
|
|
425
538
|
|
|
426
|
-
`proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The remaining frontmatter and body are the proposed new content.
|
|
539
|
+
`proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The remaining frontmatter and body are the proposed new content. A proposal's `target_key` MUST resolve to a `canon` zone; `accept` refuses any other target (`target_is_canon`, ADR 0035).
|
|
427
540
|
|
|
428
|
-
`textus accept <proposal-key>` is **
|
|
541
|
+
`textus accept <proposal-key>` is a **transition** (not a capability) that requires the **`author` capability**: the resolved role must hold `author` (the single trust anchor — `human` by default). It copies the patch into the target zone, records provenance (originating proposal key, original role, original timestamp) in the audit log, and removes the proposal entry. The `reject` transition likewise requires `author`. Roles holding only `propose` (e.g. `agent`) can propose but cannot accept or reject.
|
|
429
542
|
|
|
430
543
|
### 5.6 Audit log
|
|
431
544
|
|
|
@@ -492,10 +605,10 @@ Schemas may declare per-field ownership and version history. The `fields:` and `
|
|
|
492
605
|
fields:
|
|
493
606
|
full_name: { type: string, maintained_by: human }
|
|
494
607
|
embedding: { type: array, maintained_by: agent }
|
|
495
|
-
updated_at: { type: time, maintained_by:
|
|
608
|
+
updated_at: { type: time, maintained_by: automation }
|
|
496
609
|
```
|
|
497
610
|
|
|
498
|
-
`maintained_by` values are free-form strings.
|
|
611
|
+
`maintained_by` values are free-form role-name strings (e.g. `human | agent | automation`). They name the role expected to own a field; values that match no declared role do not affect role-authority validation and pass through unchanged.
|
|
499
612
|
|
|
500
613
|
**`evolution:` block** — top-level, declares the schema's history and migration intent:
|
|
501
614
|
|
|
@@ -511,7 +624,7 @@ evolution:
|
|
|
511
624
|
|
|
512
625
|
**Defaults:** when `fields:` and `evolution:` are absent, `schema.maintained_by(field)` returns `nil` for every field and `schema.evolution` returns `{}`.
|
|
513
626
|
|
|
514
|
-
**Override rule:**
|
|
627
|
+
**Override rule:** a role holding the `author` capability (the trust anchor — `human` by default) is permitted to write any `maintained_by` field, regardless of declared owner. The trust anchor overrides agent-maintained fields by design: schema field ownership (`maintained_by:`) makes the boundary explicit, not implicit. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
|
|
515
628
|
|
|
516
629
|
### 5.9 Row transforms
|
|
517
630
|
|
|
@@ -519,6 +632,8 @@ Row transforms are RPC hooks on the `:transform_rows` event. See §5.10.
|
|
|
519
632
|
|
|
520
633
|
### 5.10 Hooks
|
|
521
634
|
|
|
635
|
+
This section is the normative event table. For the hook-author's guide (how to define and test hooks), see [`docs/events.md`](docs/events.md).
|
|
636
|
+
|
|
522
637
|
textus has a single hook registration verb: `Textus.hook { |reg| reg.on(event, name, **opts) { ... } }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/**/*.rb` are `load`ed at `Store#initialize` in alphabetical order by full path; the store-scoped loader drains the queued blocks and invokes each with its own registry.
|
|
523
638
|
|
|
524
639
|
The subdirectory layout under `hooks/` is organizational only; the registered event and name come from the DSL call, not the file path.
|
|
@@ -547,24 +662,24 @@ end
|
|
|
547
662
|
| `:validate` | rpc | caps: | issues array | aborts doctor |
|
|
548
663
|
| `:entry_put` | pubsub | ctx:, key:, envelope: | (discarded) | logged |
|
|
549
664
|
| `:entry_deleted` | pubsub | ctx:, key: | (discarded) | logged |
|
|
550
|
-
| `:
|
|
665
|
+
| `:entry_fetched` | pubsub | ctx:, key:, envelope:, change: | (discarded) | logged |
|
|
551
666
|
| `:build_completed` | pubsub | ctx:, key:, envelope:, sources: | (discarded) | logged |
|
|
552
667
|
| `:proposal_accepted` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
|
|
553
668
|
| `:file_published` | pubsub | ctx:, key:, envelope:, source:, target: | (discarded) | logged |
|
|
554
669
|
| `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
|
|
555
670
|
| `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
|
|
556
671
|
| `:store_loaded` | pubsub | ctx: | (discarded) | logged |
|
|
557
|
-
| `:
|
|
558
|
-
| `:
|
|
559
|
-
| `:
|
|
672
|
+
| `:fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
|
|
673
|
+
| `:fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
|
|
674
|
+
| `:fetch_backgrounded` | pubsub | ctx:, key:, started_at:, budget_ms: | (discarded) | logged |
|
|
560
675
|
|
|
561
|
-
The three `:
|
|
676
|
+
The three `:fetch_*` lifecycle events report the progress and failures of background (timed_sync) fetches.
|
|
562
677
|
|
|
563
|
-
**`:
|
|
678
|
+
**`:fetch_started`** fires immediately before an intake handler is invoked. `mode:` is one of `"sync"` or `"timed_sync"`.
|
|
564
679
|
|
|
565
|
-
**`:
|
|
680
|
+
**`:fetch_failed`** fires when an intake handler raises. `error_class:` is the exception class name string; `error_message:` is `e.message`.
|
|
566
681
|
|
|
567
|
-
**`:
|
|
682
|
+
**`:fetch_backgrounded`** fires when a `timed_sync` fetch exceeds its budget and is handed off to a background thread. `started_at:` is an ISO-8601 UTC string; `budget_ms:` is the configured deadline as an integer.
|
|
568
683
|
|
|
569
684
|
**Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
|
|
570
685
|
|
|
@@ -587,32 +702,40 @@ A manifest MAY declare a top-level `rules:` block — a list of rule blocks matc
|
|
|
587
702
|
|
|
588
703
|
```yaml
|
|
589
704
|
rules:
|
|
590
|
-
- match:
|
|
591
|
-
|
|
705
|
+
- match: feeds.**
|
|
706
|
+
fetch: { ttl: 6h, on_stale: warn }
|
|
592
707
|
|
|
593
|
-
- match:
|
|
594
|
-
|
|
708
|
+
- match: feeds.calendar.**
|
|
709
|
+
fetch: { ttl: 30m, on_stale: timed_sync, sync_budget_ms: 800 }
|
|
595
710
|
intake_handler_allowlist: [ical-events]
|
|
596
711
|
|
|
597
|
-
- match:
|
|
598
|
-
|
|
599
|
-
|
|
712
|
+
- match: proposals.**
|
|
713
|
+
guard:
|
|
714
|
+
accept: [schema_valid, author_held]
|
|
600
715
|
```
|
|
601
716
|
|
|
602
717
|
**Slots (all optional within a block):**
|
|
603
718
|
|
|
604
719
|
| Slot | Type | Meaning |
|
|
605
720
|
|---|---|---|
|
|
606
|
-
| `
|
|
721
|
+
| `fetch` | `{ ttl, on_stale, sync_budget_ms, fetch_timeout_seconds }` | Freshness budget for intake entries. `on_stale` is `warn` (default), `sync`, or `timed_sync`. |
|
|
607
722
|
| `intake_handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
|
|
608
|
-
| `
|
|
609
|
-
| `retention` |
|
|
723
|
+
| `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `delete`, `mv`, `accept`, `reject`, `fetch`). Predicate names are drawn from the closed vocabulary (`zone_writable_by`, `schema_valid`, `author_held`, `target_is_canon`, `etag_match`, `fresh_within`); parameterized predicates use `{ name: param }` form, e.g. `{ fresh_within: "1h" }`. Enforced — the transition refuses (`guard_failed`) if any predicate fails; the topology refusal keeps the `write_forbidden` code. |
|
|
724
|
+
| `retention` | `{ expire_after:, archive_after: }` | Pruning policy for matched leaves. Duration strings: `30s`, `90m`, `12h`, `30d`, or bare integer seconds. `textus retain --as=ROLE` sweeps matched leaves: `expire_after` is checked first, so a leaf older than `expire_after` is deleted (and audited); otherwise a leaf older than `archive_after` is copied to `<store>/archive/<relative-path>` and then deleted. Age is measured from the leaf file's modification time. The `--as` role must be allowed to write the matched zone. |
|
|
725
|
+
|
|
726
|
+
Both retention windows are optional, and `expire_after` is evaluated before
|
|
727
|
+
`archive_after` — so when both apply, a leaf past the (longer) `expire_after`
|
|
728
|
+
window is deleted rather than archived. The usual configuration is therefore
|
|
729
|
+
`archive_after < expire_after` (archive a leaf, then delete it once older).
|
|
730
|
+
`textus retain --as=ROLE` runs the sweep; `--prefix` and `--zone` narrow it, and
|
|
731
|
+
any leaf whose zone the `--as` role cannot write is reported as a failure rather
|
|
732
|
+
than aborting the run.
|
|
610
733
|
|
|
611
734
|
**Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
|
|
612
735
|
|
|
613
|
-
**Resolution.** For each key textus computes a `RuleSet {
|
|
736
|
+
**Resolution.** For each key textus computes a `RuleSet { fetch, intake_handler_allowlist, guard, retention }` by walking every block whose `match` matches the key, ranked by specificity. **Per slot, the most specific block wins.** Two blocks of equal specificity that match the same key and fill the same slot is a manifest error reported by `textus doctor` (`rule_ambiguity`).
|
|
614
737
|
|
|
615
|
-
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key plus
|
|
738
|
+
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key plus the effective guard predicate names for every write transition.
|
|
616
739
|
|
|
617
740
|
### 5.12 Storage formats
|
|
618
741
|
|
|
@@ -682,7 +805,7 @@ The frontmatter `name:` field, when present, must match the file's basename (wit
|
|
|
682
805
|
- Existing files without a uid continue to work. The envelope shows `"uid": null` until a put mints one.
|
|
683
806
|
- `text` entries have no metadata channel and therefore no uid; their envelope always shows `"uid": null`.
|
|
684
807
|
|
|
685
|
-
Entries in `
|
|
808
|
+
Entries in a `derived` zone SHOULD additionally carry the `generated:` block defined in §5.2. Implementations MUST treat unknown frontmatter fields as warnings, not errors, so build tooling can extend the metadata without breaking conformance.
|
|
686
809
|
|
|
687
810
|
## 8. Envelope (the wire format)
|
|
688
811
|
|
|
@@ -691,10 +814,10 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
691
814
|
```json
|
|
692
815
|
{
|
|
693
816
|
"protocol": "textus/3",
|
|
694
|
-
"key": "
|
|
695
|
-
"zone": "
|
|
817
|
+
"key": "knowledge.network.org.jane",
|
|
818
|
+
"zone": "knowledge",
|
|
696
819
|
"owner": "textus:network",
|
|
697
|
-
"path": "/absolute/path/to/.textus/zones/
|
|
820
|
+
"path": "/absolute/path/to/.textus/zones/knowledge/network/org/jane.md",
|
|
698
821
|
"format": "markdown",
|
|
699
822
|
"_meta": { "name": "jane", "relationship": "peer", "org": "acme" },
|
|
700
823
|
"body": "Short body in Markdown.\n",
|
|
@@ -703,14 +826,14 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
703
826
|
"uid": "a1b2c3d4e5f60718",
|
|
704
827
|
"stale": false,
|
|
705
828
|
"stale_reason": null,
|
|
706
|
-
"
|
|
829
|
+
"fetching": false
|
|
707
830
|
}
|
|
708
831
|
```
|
|
709
832
|
|
|
710
833
|
**Field rules:**
|
|
711
834
|
- `protocol` MUST be the exact string `textus/3`.
|
|
712
835
|
- `key` MUST be the canonical resolved key.
|
|
713
|
-
- `zone` MUST be one of the zones declared in the manifest (`
|
|
836
|
+
- `zone` MUST be one of the zones declared in the manifest (`knowledge`, `notebook`, `feeds`, `proposals`, `artifacts` in the default Setup-1 scaffold).
|
|
714
837
|
- `path` MUST be an absolute filesystem path.
|
|
715
838
|
- `format` MUST be one of `markdown`, `json`, `yaml`, `text` (§5.12). Absent envelopes are treated as `markdown` for back-compat.
|
|
716
839
|
- `body` is the raw on-disk bytes as a UTF-8 string for every format.
|
|
@@ -718,11 +841,11 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
718
841
|
- `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
|
|
719
842
|
- `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
|
|
720
843
|
- `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
|
|
721
|
-
- `stale` is `true` when the entry's TTL has elapsed and the data has not yet been
|
|
722
|
-
- `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"
|
|
723
|
-
- `
|
|
844
|
+
- `stale` is `true` when the entry's TTL has elapsed and the data has not yet been fetched; `false` otherwise. Only populated for entries matched by a `fetch:` rule slot (typically `feeds` / quarantine zone); always `false` elsewhere.
|
|
845
|
+
- `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_fetched"`), or `null` when `stale` is `false`.
|
|
846
|
+
- `fetching` is `true` when a `timed_sync` background fetch is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
|
|
724
847
|
|
|
725
|
-
> **Note:** `list`/`where` envelopes do **not** include `stale`, `stale_reason`, or `
|
|
848
|
+
> **Note:** `list`/`where` envelopes do **not** include `stale`, `stale_reason`, or `fetching` — freshness annotation is only provided by `get`.
|
|
726
849
|
|
|
727
850
|
Errors use a distinct envelope:
|
|
728
851
|
|
|
@@ -731,8 +854,9 @@ Errors use a distinct envelope:
|
|
|
731
854
|
"protocol": "textus/3",
|
|
732
855
|
"ok": false,
|
|
733
856
|
"code": "write_forbidden",
|
|
734
|
-
"message": "
|
|
735
|
-
"
|
|
857
|
+
"message": "writing 'knowledge.identity.self' (zone 'knowledge') needs capability 'author'",
|
|
858
|
+
"hint": "held by: human; pass --as=<role>",
|
|
859
|
+
"details": { "key": "knowledge.identity.self", "zone": "knowledge", "verb": "author", "holders": ["human"] }
|
|
736
860
|
}
|
|
737
861
|
```
|
|
738
862
|
|
|
@@ -743,7 +867,7 @@ Errors use a distinct envelope:
|
|
|
743
867
|
| `unknown_key` | Key does not resolve | 1 |
|
|
744
868
|
| `bad_frontmatter` | YAML parse failed or `name:` mismatch | 1 |
|
|
745
869
|
| `schema_violation` | Required field missing or wrong type | 1 |
|
|
746
|
-
| `write_forbidden` | Resolved role
|
|
870
|
+
| `write_forbidden` | Resolved role lacks the capability the zone-kind requires | 1 |
|
|
747
871
|
| `etag_mismatch` | Concurrent write detected | 1 |
|
|
748
872
|
| `io_error` | Filesystem failure | 64 |
|
|
749
873
|
| `usage` | CLI argument error | 2 |
|
|
@@ -773,24 +897,24 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
773
897
|
| `pulse [--since=N]` | read | any |
|
|
774
898
|
| `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
|
|
775
899
|
| `delete K --if-etag=E --as=R` | write | per zone |
|
|
776
|
-
| `
|
|
777
|
-
| `
|
|
778
|
-
| `build [--prefix=K] [--dry-run]` | write | `
|
|
779
|
-
| `accept K --as=human` | write | `human` |
|
|
900
|
+
| `fetch KEY --as=automation` | write | `fetch`-holder (typically `automation`) |
|
|
901
|
+
| `fetch stale [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
|
|
902
|
+
| `build [--prefix=K] [--dry-run]` | write | `build`-holder (typically `automation`) |
|
|
903
|
+
| `accept K --as=human` | write | `author`-holder (typically `human`) |
|
|
780
904
|
| `init` | write | `human` |
|
|
781
905
|
| `schema {show,init,diff,migrate}` | read/write | `human` for writes |
|
|
782
906
|
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
|
|
783
907
|
| `key uid K` | read | any |
|
|
784
908
|
|
|
785
|
-
**`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
|
|
909
|
+
**`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 capabilities:
|
|
786
910
|
|
|
787
911
|
```json
|
|
788
912
|
{
|
|
789
913
|
"agent_quickstart": {
|
|
790
914
|
"read_verbs": ["boot", "get", "list", "audit", "pulse", "freshness", "doctor"],
|
|
791
|
-
"write_verbs": ["put KEY --as
|
|
792
|
-
"writable_zones": ["
|
|
793
|
-
"propose_zone": "
|
|
915
|
+
"write_verbs": ["put KEY --as=agent --stdin"],
|
|
916
|
+
"writable_zones": ["proposals"],
|
|
917
|
+
"propose_zone": "proposals",
|
|
794
918
|
"latest_seq": 1842
|
|
795
919
|
}
|
|
796
920
|
}
|
|
@@ -803,14 +927,14 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
803
927
|
```json
|
|
804
928
|
{
|
|
805
929
|
"cursor": 1845,
|
|
806
|
-
"changed": [ { "seq": 1843, "key": "
|
|
807
|
-
"stale": [ "
|
|
808
|
-
"pending_review": [ "
|
|
930
|
+
"changed": [ { "seq": 1843, "key": "knowledge.notes.x", "verb": "put", "role": "human", "ts": "..." } ],
|
|
931
|
+
"stale": [ "artifacts.marketplace" ],
|
|
932
|
+
"pending_review": [ "proposals.proposal.123" ],
|
|
809
933
|
"doctor": { "ok": true, "warn": 0, "fail": 0 }
|
|
810
934
|
}
|
|
811
935
|
```
|
|
812
936
|
|
|
813
|
-
`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
|
|
937
|
+
`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 queue 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`.
|
|
814
938
|
|
|
815
939
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
816
940
|
|
|
@@ -828,9 +952,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
828
952
|
{
|
|
829
953
|
"verb": "freshness",
|
|
830
954
|
"rows": [
|
|
831
|
-
{ "key": "
|
|
832
|
-
"zone": "
|
|
833
|
-
"
|
|
955
|
+
{ "key": "feeds.upstream.notes",
|
|
956
|
+
"zone": "feeds",
|
|
957
|
+
"last_fetched_at": "2026-05-21T13:21:17Z",
|
|
834
958
|
"age_seconds": 65000,
|
|
835
959
|
"ttl_seconds": 43200,
|
|
836
960
|
"on_stale": "warn",
|
|
@@ -840,9 +964,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
840
964
|
}
|
|
841
965
|
```
|
|
842
966
|
|
|
843
|
-
Each row reports one entry's verdict (`fresh`, `stale`, `
|
|
967
|
+
Each row reports one entry's verdict (`fresh`, `stale`, `never_fetched`, or `no_policy`) against its matched `fetch:` rule. `textus build` consumes its own staleness signal and executes derived entries' projections under a `build`-holding role (`automation` by default); `--dry-run` prints the plan without executing.
|
|
844
968
|
|
|
845
|
-
`textus accept K --as=human` promotes a pending entry into its target zone: it copies the patch body into the target key, deletes the pending entry, and writes one audit line per side (§audit). Only the `human`
|
|
969
|
+
`textus accept K --as=human` promotes a pending entry into its target zone: it copies the patch body into the target key, deletes the pending entry, and writes one audit line per side (§audit). Only a role holding the `author` capability (the trust anchor — `human` by default) may invoke `accept`.
|
|
846
970
|
|
|
847
971
|
`textus init` scaffolds a fresh `.textus/` tree (manifest, zones, schemas, audit log) under the current directory with a default manifest. Customize by editing `.textus/manifest.yaml` after init.
|
|
848
972
|
|
|
@@ -858,7 +982,7 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
|
|
|
858
982
|
|
|
859
983
|
## 10.2 `textus doctor`
|
|
860
984
|
|
|
861
|
-
`textus doctor` returns a health-check envelope: `{ "protocol": "textus/3", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `hooks`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `rule_ambiguity`, `
|
|
985
|
+
`textus doctor` returns a health-check envelope: `{ "protocol": "textus/3", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `protocol_version`, `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `hooks`, `intake_registration`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `rule_ambiguity`, `handler_allowlist`, `fetch_locks`, `proposal_targets`. Additional registered `:validate` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
|
|
862
986
|
|
|
863
987
|
## 11. Versioning
|
|
864
988
|
|
|
@@ -873,7 +997,7 @@ The reference Ruby gem follows semver independently and speaks `textus/3`.
|
|
|
873
997
|
|
|
874
998
|
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.
|
|
875
999
|
|
|
876
|
-
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/
|
|
1000
|
+
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/agents-mcp.md`](docs/agents-mcp.md).
|
|
877
1001
|
|
|
878
1002
|
## 12. Conformance fixtures
|
|
879
1003
|
|
|
@@ -883,13 +1007,13 @@ A conformant implementation MUST pass these fixtures (the reference test suite s
|
|
|
883
1007
|
Given a manifest with `working.network.org` → `working/network/org` (nested), schema `person`, and a file `.textus/zones/working/network/org/jane.md` with valid frontmatter, `textus get working.network.org.jane --output=json` returns the canonical envelope with `etag` matching the file's sha256.
|
|
884
1008
|
|
|
885
1009
|
**Fixture B — Role gate on write:**
|
|
886
|
-
Given a manifest entry where `key: identity.self` lives in the `identity` zone (
|
|
1010
|
+
Given a manifest entry where `key: identity.self` lives in the `identity` zone (`kind: canon`, requiring the `author` capability), `textus put identity.self --stdin --as=agent` (where `agent` holds only `propose`) returns the error envelope with `code: "write_forbidden"` and exit code 1.
|
|
887
1011
|
|
|
888
1012
|
**Fixture C — Schema violation:**
|
|
889
1013
|
Given the `person` schema and a `put` whose frontmatter omits `relationship`, the result is the error envelope with `code: "schema_violation"`, `details.missing: ["relationship"]`, and exit code 1.
|
|
890
1014
|
|
|
891
1015
|
**Fixture D — Staleness detection:**
|
|
892
|
-
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes,
|
|
1016
|
+
Given a manifest entry `intake.notes` matched by a `rules: [{ match: intake.notes, fetch: { ttl: 1h } }]` block and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus freshness --output=json` includes a row for `intake.notes` with `status: "stale"`. Calling `textus freshness` does NOT trigger a fetch.
|
|
893
1017
|
|
|
894
1018
|
**Fixture E — Projection build:**
|
|
895
1019
|
Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: projection }` clause selects fields from `working.projects` entries, `textus build derived.catalogs.skills` materializes the derived entry on disk with frontmatter and body matching the projected shape, and updates `generated.at` to the build timestamp.
|
|
@@ -904,13 +1028,13 @@ Given a manifest entry with `publish_to: <path>`, a successful `textus build` fo
|
|
|
904
1028
|
Every successful write verb (`put`, `delete`, `build`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
|
|
905
1029
|
|
|
906
1030
|
**Fixture I — Pending → accept:**
|
|
907
|
-
Given a
|
|
1031
|
+
Given a proposal entry `proposals.knowledge.self.patch` proposing a change to `knowledge.identity.self`, `textus accept proposals.knowledge.self.patch --as=human` copies the patch body into the target key, deletes the proposal entry, and appends two audit lines (one for the target write, one for the proposals delete) in that order.
|
|
908
1032
|
|
|
909
1033
|
## 13. Why not X?
|
|
910
1034
|
|
|
911
1035
|
- **Why not MCP?** MCP is a transport; textus is a data model. The two compose: a 50-line MCP server can wrap `textus get/put` as tools. textus exists because the *shape* of agent-readable project memory deserves a standalone spec, separate from how it's served.
|
|
912
1036
|
|
|
913
|
-
- **Why doesn't textus execute
|
|
1037
|
+
- **Why doesn't textus execute external build commands itself?** textus is a dataflow oracle, not a build runner. The moment a spec includes process execution, it inherits shell-injection surface, OS-portability concerns, and signal-handling semantics — and ends up duplicating whatever build system the consumer already runs (make, rake, just, lefthook, CI). Keeping execution external means a Python or TypeScript port of `textus/3` only has to parse YAML and emit JSON; it doesn't have to spawn processes safely. External build systems stay the executor; textus stays a data tool.
|
|
914
1038
|
|
|
915
1039
|
- **Why not plain Markdown vaults (Obsidian / Foam)?** No schema enforcement, no write-gating, no addressable wire format. Fine for human notes; underspecified for agents that must act on the contents deterministically.
|
|
916
1040
|
|
|
@@ -924,28 +1048,9 @@ Given a review entry `review.identity.self.patch` proposing a change to `identit
|
|
|
924
1048
|
|
|
925
1049
|
## 13.1 Layered architecture (internal)
|
|
926
1050
|
|
|
927
|
-
Textus internals are organized into four layers
|
|
1051
|
+
Textus internals are organized into four one-way layers — **Interface** (`cli/`, `mcp/`) → **Application** (`application/` use cases) → **Domain** (`domain/` pure values) → **Infrastructure** (`infra/` adapters). Each layer imports only from the one beneath it. Plugin authors touch only the Hook DSL and the manifest YAML; the layering is internal and may evolve.
|
|
928
1052
|
|
|
929
|
-
|
|
930
|
-
- **Application** (`lib/textus/application/`) — Use cases: `Read::Get`, `Write::Put`, `Write::RefreshWorker`, `Write::RefreshOrchestrator`, `Write::RefreshAll`, `Maintenance::Migrate`, etc. Orchestrate domain + infra; no business rules.
|
|
931
|
-
- **Domain** (`lib/textus/domain/`) — Pure values: `Authorizer`, `Permission`, `Freshness::{Policy,Verdict,Evaluator}`, `Action`, `Outcome`, `Sentinel`, `Staleness`. No I/O, no globals, testable without disk.
|
|
932
|
-
- **Infrastructure** (`lib/textus/infra/`) — Adapters: `Storage::FileStore`, `AuditLog`, `AuditSubscriber`, `Publisher`, `Clock`, `Refresh::Lock`, `Refresh::Detached`, `BuildLock`. Wrap OS / library primitives.
|
|
933
|
-
|
|
934
|
-
The `lib/textus/store/`, `lib/textus/manifest/`, `lib/textus/hooks/` namespaces are infrastructure adapters that predate this split and remain at their existing paths for backward-compat with the plugin DSL.
|
|
935
|
-
|
|
936
|
-
Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:resolve_intake, ...) }`, `reg.on(:entry_refreshed, ...)`, etc.) and the manifest YAML schema. The layering is internal and may evolve.
|
|
937
|
-
|
|
938
|
-
Both read and write paths flow through the application layer:
|
|
939
|
-
|
|
940
|
-
- **Reads** flow through `Textus::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `container:` and a `call:`.
|
|
941
|
-
- **Writes** flow through `Textus::Write::{Put,Delete,Mv,Accept,Reject,Publish,RefreshWorker}`. Permission checks happen at the use-case layer (via `Domain::Authorizer#authorize_write!`); the audit-append invariant lives in `Textus::Envelope::IO::Writer`.
|
|
942
|
-
- `Textus::Call` is the slim per-invocation record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from `Textus::Container`, not from the Call.
|
|
943
|
-
- `Textus::Store` is the composition root and verb dispatcher. CLI verbs and the
|
|
944
|
-
MCP gate call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`).
|
|
945
|
-
Verbs are looked up in the static `Textus::Dispatcher::VERBS` table; adding a
|
|
946
|
-
use case is a single entry in `VERBS` plus the class.
|
|
947
|
-
|
|
948
|
-
See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
|
|
1053
|
+
See [`docs/architecture/README.md`](docs/architecture/README.md) for an ASCII diagram and the full read-path walkthrough.
|
|
949
1054
|
|
|
950
1055
|
## 14. Open questions (v3.x scope)
|
|
951
1056
|
|
|
@@ -963,10 +1068,10 @@ A `textus/3` implementation MUST:
|
|
|
963
1068
|
- [ ] Read `_meta` + body from `.md` files; validate against the named schema.
|
|
964
1069
|
- [ ] Read `_meta` from the top-level `_meta` hash in `.json` / `.yaml` files; validate against the named schema.
|
|
965
1070
|
- [ ] Compute `sha256:<hex>` etags over raw file bytes.
|
|
966
|
-
- [ ] Refuse writes whose resolved role
|
|
1071
|
+
- [ ] Refuse writes whose resolved role lacks the capability the target zone-kind requires with `write_forbidden`.
|
|
967
1072
|
- [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
|
|
968
1073
|
- [ ] Use the error codes in §8 and the exit-code table.
|
|
969
|
-
- [ ] Implement `textus freshness` per §5.1 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|stale|
|
|
1074
|
+
- [ ] Implement `textus freshness` per §5.1 and §9, walking each entry, matching it against the top-level `rules:` block, and reporting `fresh|stale|never_fetched|no_policy` without invoking any fetch.
|
|
970
1075
|
- [ ] Pass the conformance fixtures A–I in §12.
|
|
971
1076
|
|
|
972
1077
|
A `textus/3` implementation MAY:
|
|
@@ -977,27 +1082,20 @@ A `textus/3` implementation MAY:
|
|
|
977
1082
|
|
|
978
1083
|
## 16. Migrating from textus/2
|
|
979
1084
|
|
|
980
|
-
textus
|
|
981
|
-
|
|
982
|
-
1. Install textus **0.11.x** first.
|
|
983
|
-
2. Run `textus migrate --to=textus/3` (available in 0.11.x only). This rewrites `manifest.yaml`, renames the `inbox/` zone directory to `intake/`, sweeps frontmatter `owner:` fields, writes an audit-log marker, and reports legacy hook-DSL call sites for manual review.
|
|
984
|
-
3. Upgrade to textus **0.12.0**.
|
|
985
|
-
4. If `.textus/audit.log` contains pre-0.11.0 rows with `role: ai|script|build`, run `textus audit-rewrite-legacy-roles` once (one-shot verb; removed in 0.13.0).
|
|
986
|
-
|
|
987
|
-
**textus doctor refuses textus/2 stores.** The doctor check `protocol_version` emits an `error`-level issue when `manifest.yaml` carries `version: textus/2`. Install 0.11.x and migrate before upgrading to 0.12.0.
|
|
1085
|
+
textus does not ship a built-in textus/2 → textus/3 migrator. The historical upgrade path (via the one-shot `textus migrate` in the 0.11.x line) is recorded in `CHANGELOG.md` §0.11.0. `textus doctor` refuses a store still declaring `version: textus/2`. The textus/2 → textus/3 rename table is kept below for reference.
|
|
988
1086
|
|
|
989
1087
|
**Vocabulary summary** (textus/2 → textus/3 rename table, for reference):
|
|
990
1088
|
|
|
991
|
-
| Category | textus/2 | textus/3 |
|
|
1089
|
+
| Category | textus/2 | textus/3 (current) |
|
|
992
1090
|
|---|---|---|
|
|
993
1091
|
| Actor | `ai` | `agent` |
|
|
994
|
-
| Actor | `script` | `
|
|
995
|
-
| Actor | `build` | `
|
|
1092
|
+
| Actor | `script` | `automation` |
|
|
1093
|
+
| Actor | `build` | `automation` |
|
|
996
1094
|
| Zone | `inbox` | `intake` |
|
|
997
|
-
| Manifest | `writable_by:` | `
|
|
1095
|
+
| Manifest | `writable_by:` | (removed — authority is derived from `kind:` × `can:`) |
|
|
998
1096
|
| Manifest | `policies:` | `rules:` |
|
|
999
1097
|
| Manifest | `handler_allowlist:` | `intake_handler_allowlist:` |
|
|
1000
|
-
| Manifest | `promote_requires:` | `
|
|
1098
|
+
| Manifest | `promote_requires:` | `guard: { accept: [...] }` |
|
|
1001
1099
|
| Manifest | `projection:` | `compute: { kind: projection, ... }` |
|
|
1002
1100
|
| Manifest | `generator:` | `compute: { kind: external, ... }` |
|
|
1003
1101
|
| Hook event | `:intake` | `:resolve_intake` |
|
|
@@ -1005,24 +1103,91 @@ textus 0.12.0 does not ship a built-in migrator. Upgrade path:
|
|
|
1005
1103
|
| Hook event | `:check` | `:validate` |
|
|
1006
1104
|
| Hook event | `:put` | `:entry_put` |
|
|
1007
1105
|
| Hook event | `:deleted` | `:entry_deleted` |
|
|
1008
|
-
| Hook event | `:refreshed` | `:
|
|
1106
|
+
| Hook event | `:refreshed` | `:entry_fetched` |
|
|
1009
1107
|
| Hook event | `:built` | `:build_completed` |
|
|
1010
1108
|
| Hook event | `:accepted` | `:proposal_accepted` |
|
|
1011
1109
|
| Hook event | `:reject` | `:proposal_rejected` |
|
|
1012
1110
|
| Hook event | `:published` | `:file_published` |
|
|
1013
1111
|
| Hook event | `:mv` | `:entry_renamed` |
|
|
1014
1112
|
| Hook event | `:loaded` | `:store_loaded` |
|
|
1015
|
-
| Hook event | `:refresh_began` | `:
|
|
1016
|
-
| Hook event | `:refresh_detached` | `:
|
|
1017
|
-
| Hook event | `:refresh_failed` | `:
|
|
1113
|
+
| Hook event | `:refresh_began` | `:fetch_started` |
|
|
1114
|
+
| Hook event | `:refresh_detached` | `:fetch_backgrounded` |
|
|
1115
|
+
| Hook event | `:refresh_failed` | `:fetch_failed` |
|
|
1018
1116
|
| Hook DSL | `Textus.hook(ev, name)` / sugar | `Textus.on(ev, name)` |
|
|
1019
1117
|
| Compute field | `projection.reduce:` | `compute.transform:` |
|
|
1020
1118
|
| `_meta` key | `reducer` | `transform` |
|
|
1021
1119
|
| CLI flag | `--format=json` (envelope) | `--output=json` |
|
|
1022
|
-
| CLI verb | `refresh-stale` | `
|
|
1120
|
+
| CLI verb | `refresh-stale` | `fetch stale` |
|
|
1023
1121
|
| CLI verb | `policy list/explain` | `rule list/explain` |
|
|
1024
1122
|
|
|
1025
|
-
**
|
|
1123
|
+
**Hook migration.** Legacy event names / DSL methods must be renamed to the textus/3 forms above before a hook will load; see `CHANGELOG.md` §0.11.0 for the full event-rename detail.
|
|
1124
|
+
|
|
1125
|
+
### 16.1 Breaking changes in 0.31.0 (capability-based roles)
|
|
1126
|
+
|
|
1127
|
+
0.31.0 replaced declared per-zone write policies with **derived** authority and renamed the `refresh` transition to `fetch`. These keys/values are no longer accepted:
|
|
1128
|
+
|
|
1129
|
+
| Removed / renamed (≤ 0.30) | 0.31.0 form |
|
|
1130
|
+
|---|---|
|
|
1131
|
+
| `zones[*].write_policy:` | (removed) authority is derived: `role.can ⊇ { verb_for(zone.kind) }` |
|
|
1132
|
+
| `roles[*].kind:` (`accept_authority`/`generator`/`proposer`/`runner`) | `roles[*].can:` (subset of `propose`, `author`, `fetch`, `build`) |
|
|
1133
|
+
| Actors `runner`, `builder` | `automation` (`can: [fetch, build]`) by default |
|
|
1134
|
+
| `rules[*].refresh:` slot | `rules[*].fetch:` slot |
|
|
1135
|
+
| CLI `textus refresh` / `refresh stale` | `textus fetch` / `fetch stale` |
|
|
1136
|
+
| `_meta.last_refreshed_at` | `_meta.last_fetched_at` |
|
|
1137
|
+
| Promotion predicate `:human_accept` / `:accept_authority_signed` | `:author_signed` |
|
|
1138
|
+
| Envelope `refreshing` | `fetching` |
|
|
1139
|
+
|
|
1140
|
+
A manifest still declaring `write_policy:` or a role `kind:` is rejected at load. There is no compatibility alias — the breaking change requires a new wire-compatible manifest. (Wire string `textus/3` is unchanged: capabilities are a manifest concept and never appear on the wire.)
|
|
1141
|
+
|
|
1142
|
+
### 16.2 Breaking changes in 0.33.0 (workspace/keep + Setup-1 scaffold)
|
|
1143
|
+
|
|
1144
|
+
0.33.0 adds the fifth coordination primitive (`workspace` zone-kind + `keep` capability), renames the capability `accept` → `author` (and predicate `accept_signed` → `author_signed`), renames zone-kind `origin` → `canon`, and renames the default scaffold zones to the Setup-1 names. These changes affect **manifest files and tooling** only — the `textus/3` wire format is **UNCHANGED** (envelope shape, audit-log schema, key grammar, and the `version: textus/3` field are all identical to 0.32.x).
|
|
1145
|
+
|
|
1146
|
+
**Renames (manifest and predicate vocabulary):**
|
|
1147
|
+
|
|
1148
|
+
| Removed / renamed (≤ 0.32) | 0.33.0 form |
|
|
1149
|
+
|---|---|
|
|
1150
|
+
| Zone-kind `origin` | `canon` |
|
|
1151
|
+
| Capability `accept` | `author` |
|
|
1152
|
+
| Promotion predicate `accept_signed` | `author_signed` |
|
|
1153
|
+
| Default scaffold zone `identity` | `knowledge` (identity keys live under `knowledge.identity.*`) |
|
|
1154
|
+
| Default scaffold zone `working` | `knowledge` (merged into the same `canon` zone) |
|
|
1155
|
+
| Default scaffold zone `intake` | `feeds` |
|
|
1156
|
+
| Default scaffold zone `review` | `proposals` |
|
|
1157
|
+
| Default scaffold zone `output` | `artifacts` |
|
|
1158
|
+
|
|
1159
|
+
**New in 0.33.0:**
|
|
1160
|
+
|
|
1161
|
+
| Addition | Detail |
|
|
1162
|
+
|---|---|
|
|
1163
|
+
| Zone-kind `workspace` | Agent's own durable lane. Required capability: `keep`. Bytes never auto-promote; climb to `canon` only via propose→accept. |
|
|
1164
|
+
| Capability `keep` | Authorizes writes to `workspace` zones. Default scaffold: `agent` holds `[propose, keep]`. |
|
|
1165
|
+
| Default scaffold zone `notebook` | `kind: workspace`, default owner `agent`. |
|
|
1166
|
+
| `owner:` on a zone | OPTIONAL, INFORMATIONAL — not enforced in 0.33.0 (owner-scoped enforcement is deferred). |
|
|
1167
|
+
| `desc:` on a zone | OPTIONAL — surfaces as the `purpose` field in `textus boot` zone rows. |
|
|
1168
|
+
|
|
1169
|
+
**Clarification (not a breaking change):** `accept` and `reject` are **transition verbs** (CLI commands), not capabilities. Both require the `author` capability. This has always been true; 0.33.0 makes it explicit by removing `accept` from the capability vocabulary.
|
|
1170
|
+
|
|
1171
|
+
A manifest declaring `kind: origin` or capability `accept` (in a `can:` list) is rejected at load.
|
|
1172
|
+
|
|
1173
|
+
### 16.3 Breaking changes in 0.35.0 (proposal target-canon + `author_held`)
|
|
1174
|
+
|
|
1175
|
+
0.35.0 constrains a proposal's target to a `canon` zone and renames the anchor-gate predicate. No `textus/3` wire change; no manifest-schema change.
|
|
1176
|
+
|
|
1177
|
+
**Renames (predicate vocabulary):**
|
|
1178
|
+
|
|
1179
|
+
| Removed / renamed (≤ 0.34) | 0.35.0 form |
|
|
1180
|
+
|---|---|
|
|
1181
|
+
| Promotion predicate `author_signed` | `author_held` |
|
|
1182
|
+
|
|
1183
|
+
**New in 0.35.0:**
|
|
1184
|
+
|
|
1185
|
+
| Addition | Detail |
|
|
1186
|
+
|---|---|
|
|
1187
|
+
| Floor predicate `target_is_canon` | On the `accept` base guard. A proposal's `target_key` MUST resolve to a `canon` zone; `accept` refuses any other target with `guard_failed` naming `target_is_canon`. Floor-only — not relaxable via `rules[].guard`. |
|
|
1188
|
+
| `doctor` check `proposal_targets` | Warns on queued proposals whose `target_key` is non-canon (`proposal.target_not_canon`) or unresolvable (`proposal.target_unresolved`). |
|
|
1189
|
+
|
|
1190
|
+
A `rules[].guard` block referencing the predicate by its old name `author_signed` is rejected at load (unknown predicate).
|
|
1026
1191
|
|
|
1027
1192
|
---
|
|
1028
1193
|
|