textus 0.54.0 → 0.54.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +93 -86
- data/SPEC.md +141 -297
- data/docs/reference/conventions.md +39 -55
- data/lib/textus/action/ingest.rb +103 -35
- data/lib/textus/action/pulse.rb +1 -1
- data/lib/textus/boot.rb +9 -2
- data/lib/textus/container.rb +21 -5
- data/lib/textus/core/freshness/verdict.rb +1 -1
- data/lib/textus/cursor_store.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +6 -6
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
- data/lib/textus/doctor.rb +1 -0
- data/lib/textus/envelope.rb +19 -6
- data/lib/textus/errors.rb +1 -1
- data/lib/textus/init.rb +5 -5
- data/lib/textus/layout.rb +15 -7
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -4
- data/lib/textus/manifest/schema/contract.rb +61 -0
- data/lib/textus/manifest/schema/semantics.rb +232 -0
- data/lib/textus/manifest/schema/validator.rb +24 -294
- data/lib/textus/manifest/schema.rb +1 -4
- data/lib/textus/manifest.rb +1 -3
- data/lib/textus/ports/raw_index.rb +61 -0
- data/lib/textus/produce/render.rb +4 -2
- data/lib/textus/session.rb +14 -7
- data/lib/textus/store.rb +4 -4
- data/lib/textus/surfaces/mcp/catalog.rb +22 -10
- data/lib/textus/surfaces/mcp/server.rb +73 -135
- data/lib/textus/types.rb +15 -0
- data/lib/textus/version.rb +2 -2
- data/lib/textus/workflow/runner.rb +15 -0
- metadata +37 -7
- data/lib/textus/surfaces/mcp/routing.rb +0 -51
- data/lib/textus/surfaces/mcp/session.rb +0 -9
- data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
data/SPEC.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# textus/
|
|
1
|
+
# textus/4 — Specification
|
|
2
2
|
|
|
3
|
-
**Status:**
|
|
4
|
-
**Protocol identifier:** `textus/
|
|
3
|
+
**Status:** Accepted v4.0
|
|
4
|
+
**Protocol identifier:** `textus/4`
|
|
5
5
|
**Reference implementation:** Ruby gem `textus`
|
|
6
6
|
|
|
7
7
|
> *textus* — Latin for "the fabric a text is woven from," same root as *context* (from *con-texere*, "to weave together"). This spec defines a storage shape and wire protocol for that fabric.
|
|
@@ -12,41 +12,18 @@
|
|
|
12
12
|
|
|
13
13
|
- [Conventions](#conventions)
|
|
14
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
15
|
- [2. Goals and non-goals](#2-goals-and-non-goals)
|
|
18
16
|
- [3. Storage layout](#3-storage-layout)
|
|
19
|
-
- [3.1 Store location precedence](#31-store-location-precedence)
|
|
20
17
|
- [4. Manifest](#4-manifest)
|
|
21
|
-
- [5.
|
|
22
|
-
- [5.1 Role resolution](#51-role-resolution)
|
|
23
|
-
- [5.1.1 Capabilities](#511-capabilities)
|
|
24
|
-
- [5.2 Source layer (produced entries)](#52-source-layer-produced-entries)
|
|
25
|
-
- [5.2.1 Derived source (`from: derive`)](#521-derived-source-from-derive)
|
|
26
|
-
- [5.2.2 External source (`from: external`)](#522-external-source-from-external)
|
|
27
|
-
- [5.3 Publish layer](#53-publish-layer-publish)
|
|
28
|
-
- [5.4 Intake source (`from: fetch`)](#54-intake-source-from-fetch)
|
|
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 Hooks](#59-hooks)
|
|
34
|
-
- [5.10 Rules](#510-rules)
|
|
35
|
-
- [5.11 Storage formats](#511-storage-formats)
|
|
18
|
+
- [5. Lanes and capability-based write gates](#5-lanes-and-capability-based-write-gates)
|
|
36
19
|
- [6. Schemas](#6-schemas)
|
|
37
20
|
- [7. Entry file format](#7-entry-file-format)
|
|
38
21
|
- [8. Envelope (the wire format)](#8-envelope-the-wire-format)
|
|
39
22
|
- [9. CLI surface](#9-cli-surface)
|
|
40
23
|
- [10. ETag semantics](#10-etag-semantics)
|
|
41
|
-
- [10.1 Errors carry hints](#101-errors-carry-hints)
|
|
42
|
-
- [10.2 `textus doctor`](#102-textus-doctor)
|
|
43
24
|
- [11. Versioning](#11-versioning)
|
|
44
|
-
- [11.1 Agent integration](#111-agent-integration)
|
|
45
25
|
- [12. Conformance fixtures](#12-conformance-fixtures)
|
|
46
26
|
- [13. Why not X?](#13-why-not-x)
|
|
47
|
-
- [13.1 Layered architecture (internal)](#131-layered-architecture-internal)
|
|
48
|
-
- [14. Open questions (v3.x scope)](#14-open-questions-v3x-scope)
|
|
49
|
-
- [15. Implementation checklist](#15-implementation-checklist)
|
|
50
27
|
|
|
51
28
|
---
|
|
52
29
|
|
|
@@ -60,7 +37,7 @@ meaning **only** when they appear in uppercase. The same words in lowercase
|
|
|
60
37
|
carry their ordinary English sense and impose no requirement.
|
|
61
38
|
|
|
62
39
|
Requirements are stated against any **conforming implementation** of the
|
|
63
|
-
`textus/
|
|
40
|
+
`textus/4` protocol. The Ruby gem `textus` is the reference implementation, but
|
|
64
41
|
the contract is the protocol defined here — not the gem. Where this document and
|
|
65
42
|
the implementation disagree, this document is the source of truth and the
|
|
66
43
|
implementation is the bug.
|
|
@@ -71,19 +48,19 @@ implementation is the bug.
|
|
|
71
48
|
|
|
72
49
|
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 data sources, and a list of publish targets that copy or render that data.
|
|
73
50
|
|
|
74
|
-
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
|
|
51
|
+
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 lane's kind — write authority for a lane is derived from the role's capabilities and the lane's kind. Schemas (also YAML) define what frontmatter shape each entry must have. Produced entries acquire their data via a declared `source:` (a pure projection over other entries, an external fetch, or an out-of-band workflow); that data is then optionally published to repo-relative paths — copied verbatim, or rendered through a per-target ERB template. The CLI surface (`textus get/put/list/where/schema/drain/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
|
|
75
52
|
|
|
76
53
|
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.
|
|
77
54
|
|
|
78
55
|
### 1.1 Vocabulary axes
|
|
79
56
|
|
|
80
|
-
textus/
|
|
57
|
+
textus/4 names its concepts along six axes. Reviewers who internalize these can map any part of the spec to the right category:
|
|
81
58
|
|
|
82
59
|
- **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `converge`).
|
|
83
|
-
- **Place** — where data lives:
|
|
60
|
+
- **Place** — where data lives: lanes such as `knowledge`, `notebook`, `raw`, `proposals`, `artifacts`.
|
|
84
61
|
- **Thing** — what is stored: entries, fields, keys.
|
|
85
|
-
- **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `drain`, `serve`, …).
|
|
86
|
-
- **Event** — what gets fired after an operation:
|
|
62
|
+
- **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `drain`, `serve`, `ingest`, …).
|
|
63
|
+
- **Event** — what gets fired after an operation: pub-sub events (`:entry_written`, `:entry_produced`, `:entry_published`, …).
|
|
87
64
|
- **Rule** — constraints declared in the top-level `rules:` array of the manifest.
|
|
88
65
|
|
|
89
66
|
### 1.2 The five layers
|
|
@@ -93,20 +70,20 @@ textus is organized as five composable layers. Each layer has a single responsib
|
|
|
93
70
|
| Layer | Name | Responsibility |
|
|
94
71
|
|---|---|---|
|
|
95
72
|
| L1 | **Store** | Plain-file backend: `.textus/data/<lane>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
|
|
96
|
-
| L2 | **Sources** |
|
|
73
|
+
| L2 | **Sources** | Produced entries in the `artifacts` machine lane declare a `source:` block (`from: external` + a `Textus.workflow` block) that acquires their data on `drain`. textus *describes* sources; the workflow DSL acquires data and returns it to the store. |
|
|
97
74
|
| L3 | **Source** | An entry's `source:` *acquires* **data** — a pure in-process projection from store entries (select/pluck/sort/transform), an external fetch via a handler, or an out-of-band command. Acquire-only: rendering is not a source concern. No shell execution. |
|
|
98
|
-
| L4 | **Publish** | Emits a produced entry's data to repo-relative paths, declared via a **list** of `publish:` targets. A target with no `template:` copies the data verbatim (json/yaml re-serialized without `_meta`; other formats byte-copied); a target with a `template:` renders the data through it. A `{ tree: }` target mirrors a subtree (ADR 0047). Published artifacts are clean content — textus's `_meta` provenance stays in the store. A sentinel under `.textus/.
|
|
75
|
+
| L4 | **Publish** | Emits a produced entry's data to repo-relative paths, declared via a **list** of `publish:` targets. A target with no `template:` copies the data verbatim (json/yaml re-serialized without `_meta`; other formats byte-copied); a target with a `template:` renders the data through it. A `{ tree: }` target mirrors a subtree (ADR 0047). Published artifacts are clean content — textus's `_meta` provenance stays in the store. A sentinel under `.textus/.state/sentinels/<target-rel-path>.textus-managed.json` (git-ignored runtime state) records the source, sha256, and `mode: "copy"`. |
|
|
99
76
|
| 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. |
|
|
100
77
|
|
|
101
78
|
## 2. Goals and non-goals
|
|
102
79
|
|
|
103
80
|
**Goals**
|
|
104
|
-
- Stable wire format (`textus/
|
|
81
|
+
- Stable wire format (`textus/4`) any language can speak.
|
|
105
82
|
- Deterministic read/write of structured Markdown via a CLI returning JSON.
|
|
106
83
|
- Schema-validated frontmatter using YAML schemas as data.
|
|
107
|
-
- Capability-based write gates (roles hold capabilities; write authority per
|
|
84
|
+
- Capability-based write gates (roles hold capabilities; write authority per lane is derived from the role's capabilities and the lane's kind).
|
|
108
85
|
- Optimistic concurrency via ETags.
|
|
109
|
-
- Pure declarative data sources:
|
|
86
|
+
- Pure declarative data sources: produced entries acquire their data via workflow DSL steps; rendering (ERB) is a separate publish concern.
|
|
110
87
|
- Publish derived entries to well-known paths as body-only plain files.
|
|
111
88
|
- Plain-file backend — consumers can also read raw if they prefer.
|
|
112
89
|
|
|
@@ -127,9 +104,9 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
127
104
|
.textus/
|
|
128
105
|
manifest.yaml # internal: key → subtree mapping + role/lane declarations
|
|
129
106
|
schemas/ # internal: YAML schema files
|
|
130
|
-
templates/ # internal:
|
|
131
|
-
|
|
132
|
-
.
|
|
107
|
+
templates/ # internal: ERB templates referenced by produced entries
|
|
108
|
+
workflows/ # user: Textus.workflow DSL files for produced entry acquisition
|
|
109
|
+
.state/ # runtime (git-ignored): audit log, sentinels, locks, queue, pulse cursors
|
|
133
110
|
audit.log # append-only NDJSON log of every successful write
|
|
134
111
|
sentinels/ # byte-copied publish bookkeeping (see §5.3)
|
|
135
112
|
data/ # ALL user content lives here
|
|
@@ -137,9 +114,10 @@ The root is `.textus/` at the project working directory. A typical tree:
|
|
|
137
114
|
notebook/ # lane: notebook (kind: workspace — keep-holders write; agent's own durable lane)
|
|
138
115
|
proposals/ # lane: proposals (kind: queue — propose-holders write)
|
|
139
116
|
artifacts/ # lane: artifacts (kind: machine — converge-holders write)
|
|
117
|
+
raw/ # lane: raw (kind: raw — ingest-holders write; write-once)
|
|
140
118
|
```
|
|
141
119
|
|
|
142
|
-
Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `
|
|
120
|
+
Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `workflows/`) live directly under `.textus/`; disposable runtime state (audit log, publish `sentinels/`, fetch/build locks, pulse cursors, job queue) lives under `.textus/.state/` (git-ignored, ADR 0038/0070). **All user content lives under `.textus/data/`.** Manifest `path:` fields are relative to `.textus/` — they include the `data/` prefix explicitly (e.g. `path: data/knowledge/foo.md`).
|
|
143
121
|
|
|
144
122
|
Lane directories under `data/` are conventional; their write semantics are derived from the lane's declared `kind:` (and the capabilities roles hold), not the directory name.
|
|
145
123
|
|
|
@@ -157,11 +135,11 @@ When (1) or (2) names a path that has no `manifest.yaml`, the CLI exits with `io
|
|
|
157
135
|
|
|
158
136
|
## 4. Manifest
|
|
159
137
|
|
|
160
|
-
The manifest declares: (a) which roles exist and the capabilities each holds, (b) which
|
|
138
|
+
The manifest declares: (a) which roles exist and the capabilities each holds, (b) which lanes exist and each lane'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 lane iff it holds the capability the lane's kind requires (§5).
|
|
161
139
|
|
|
162
140
|
```yaml
|
|
163
141
|
# .textus/manifest.yaml
|
|
164
|
-
version: textus/
|
|
142
|
+
version: textus/4
|
|
165
143
|
|
|
166
144
|
roles:
|
|
167
145
|
- { name: human, can: [author, propose] }
|
|
@@ -208,7 +186,7 @@ audit:
|
|
|
208
186
|
keep: 5 # rotated files to retain (default: 5)
|
|
209
187
|
```
|
|
210
188
|
|
|
211
|
-
Zone names are conventional — write authority comes from each
|
|
189
|
+
Zone names are conventional — write authority comes from each lane's declared `kind:` crossed with the capabilities roles hold (§5); rename lanes freely.
|
|
212
190
|
|
|
213
191
|
**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).
|
|
214
192
|
|
|
@@ -225,7 +203,7 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
|
|
|
225
203
|
|
|
226
204
|
**The `publish:` list (ADR 0052, ADR 0094).** Publishing is configured by a `publish:` **list** of targets; each element is exactly one of a to-target `{ to:, template?:, inject_boot?: }` (file emit, §5.3) or a tree-target `{ tree: }` (subtree mirror, below). The legacy *map* forms (`publish: { to: [...] }`, `publish: { tree: ... }`) and the older top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message — `publish:` is a list, and a mirror is a `{ tree: }` element of it.
|
|
227
205
|
|
|
228
|
-
**Subtree mirror (a `{ tree: }` target).** A nested manifest entry MAY include a `{ tree: "dir" }` target to mirror its entire stored subtree (`
|
|
206
|
+
**Subtree mirror (a `{ tree: }` target).** A nested manifest entry MAY include a `{ tree: "dir" }` target to mirror its entire stored subtree (`data/<lane>/**`) to a single target directory, preserving relative layout (case and extension preserved). It is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every drain the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). When a `{ tree: }` target directory overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
|
|
229
207
|
|
|
230
208
|
```yaml
|
|
231
209
|
- key: knowledge.skills
|
|
@@ -242,44 +220,46 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
|
|
|
242
220
|
|
|
243
221
|
## 5. Lanes and capability-based write gates
|
|
244
222
|
|
|
245
|
-
Write authority is **derived**, never declared per-
|
|
223
|
+
Write authority is **derived**, never declared per-lane. Each lane declares a `kind:`; each lane-kind requires one capability to write to it. A role may write a lane iff its capability set (`role.can`) contains the verb that lane-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.
|
|
246
224
|
|
|
247
225
|
The kind→verb mapping is closed:
|
|
248
226
|
|
|
249
|
-
|
|
|
227
|
+
| Lane `kind` | Required capability | Meaning |
|
|
250
228
|
|---|---|---|
|
|
251
229
|
| `canon` | `author` | Authored truth — only the trust anchor writes directly. |
|
|
252
230
|
| `workspace` | `keep` | Agent's own durable lane — bytes never auto-promote; climb to `canon` only via propose→accept. |
|
|
253
|
-
| `machine` | `converge` | Machine-maintained:
|
|
231
|
+
| `machine` | `converge` | Machine-maintained: computed outputs produced by `drain`. |
|
|
254
232
|
| `queue` | `propose` | Proposals awaiting promotion. |
|
|
233
|
+
| `raw` | `ingest` | Write-once external source material (URL bookmarks, files, assets). |
|
|
255
234
|
|
|
256
|
-
This is a **bijection** (
|
|
235
|
+
This is a **bijection** (lane-kind ⟺ capability) (ADR 0091, extended by ADR 0116 with the `raw → ingest` pair): each lane-kind maps to exactly one capability.
|
|
257
236
|
|
|
258
|
-
`owner:` on a
|
|
237
|
+
`owner:` on a lane is OPTIONAL, INFORMATIONAL metadata (not enforced in 0.33.0 — owner-scoped enforcement is deferred). `desc:` on a lane is optional; the value surfaces as the `purpose` field in `textus boot` lane rows.
|
|
259
238
|
|
|
260
|
-
Default scaffold — Setup-1 (roles `human=[author, propose]`, `agent=[propose, keep]`, `automation=[converge]`):
|
|
239
|
+
Default scaffold — Setup-1 (roles `human=[author, propose, ingest]`, `agent=[propose, keep, ingest]`, `automation=[converge, ingest]`):
|
|
261
240
|
|
|
262
|
-
|
|
|
241
|
+
| Lane | `kind` | Required capability | Writable by (default) | Use case |
|
|
263
242
|
|---|---|---|---|---|
|
|
264
|
-
| `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network.
|
|
243
|
+
| `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network. |
|
|
265
244
|
| `notebook` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
|
|
266
|
-
| `proposals` | `queue` | `propose` | `agent`, `human` | Proposals awaiting human review via `textus accept`.
|
|
267
|
-
| `artifacts` | `machine` | `converge` | `automation` |
|
|
245
|
+
| `proposals` | `queue` | `propose` | `agent`, `human` | Proposals awaiting human review via `textus accept`. |
|
|
246
|
+
| `artifacts` | `machine` | `converge` | `automation` | Computed outputs produced by `drain` via the workflow DSL. |
|
|
247
|
+
| `raw` | `raw` | `ingest` | `human`, `agent`, `automation` | Write-once external source material: URL bookmarks, files, binary assets. |
|
|
268
248
|
|
|
269
|
-
A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role does not hold the capability the target
|
|
249
|
+
A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role does not hold the capability the target lane-kind requires, the write returns `write_forbidden` with the message `writing '<key>' (lane '<lane>') needs capability '<verb>'` and a hint naming the roles that hold it (`held by: <roles>`, or `held by: no declared role` when none do).
|
|
270
250
|
|
|
271
|
-
Every
|
|
251
|
+
Every lane MUST declare a `kind:` describing its role in the data-flow graph.
|
|
272
252
|
The vocabulary is closed: `canon` (authored truth), `workspace` (agent's own
|
|
273
|
-
durable lane), `machine` (machine-maintained
|
|
274
|
-
|
|
275
|
-
manifest MUST declare at most one `queue`
|
|
253
|
+
durable lane), `machine` (machine-maintained computed outputs), `queue`
|
|
254
|
+
(proposals awaiting promotion), `raw` (write-once external source material). A
|
|
255
|
+
manifest MUST declare at most one `queue` lane and at most one `machine` lane.
|
|
276
256
|
Because authority is derived, a manifest is rejected at load if it declares a
|
|
277
|
-
|
|
278
|
-
`converge`, `queue` ⇒ `propose`, `workspace` ⇒ `keep`, `canon` ⇒ `author
|
|
279
|
-
Coordination is keyed off the declared kind: a
|
|
280
|
-
it declares `kind: machine`, and proposals route to
|
|
281
|
-
there is no name-based fallback. A manifest with a
|
|
282
|
-
load.
|
|
257
|
+
lane whose required verb is held by **no** declared role (`machine` ⇒ a role with
|
|
258
|
+
`converge`, `queue` ⇒ `propose`, `workspace` ⇒ `keep`, `canon` ⇒ `author`,
|
|
259
|
+
`raw` ⇒ `ingest`). Coordination is keyed off the declared kind: a lane is
|
|
260
|
+
machine-maintained only if it declares `kind: machine`, and proposals route to
|
|
261
|
+
the declared `queue` lane — there is no name-based fallback. A manifest with a
|
|
262
|
+
kind-less lane is rejected at load.
|
|
283
263
|
|
|
284
264
|
### 5.1 Role resolution
|
|
285
265
|
|
|
@@ -317,12 +297,12 @@ roles:
|
|
|
317
297
|
```
|
|
318
298
|
|
|
319
299
|
Capability allow-list: `propose`, `author`, `keep`, `converge`. The mapping from
|
|
320
|
-
|
|
300
|
+
lane-kind to its required capability is a **bijection** (ADR 0091, which folded
|
|
321
301
|
the former `quarantine` + `derived` kinds back into one `machine` kind — undoing
|
|
322
302
|
the two-kind split of ADR 0090): each capability authorizes exactly one
|
|
323
|
-
|
|
303
|
+
lane-kind:
|
|
324
304
|
|
|
325
|
-
| Capability | Authorizes writes to
|
|
305
|
+
| Capability | Authorizes writes to lane-kind |
|
|
326
306
|
|---|---|
|
|
327
307
|
| `author` | `canon` |
|
|
328
308
|
| `keep` | `workspace` |
|
|
@@ -337,7 +317,7 @@ spelling `fetch` — in a `can:` list is rejected at load with a hint pointing t
|
|
|
337
317
|
(a manifest declaring two or more is rejected at load). The `accept` and
|
|
338
318
|
`reject` transitions also require the `author` capability — `accept` is a
|
|
339
319
|
transition verb, not a capability. Because write authority is derived, there is
|
|
340
|
-
no `write_policy:` — instead, every declared
|
|
320
|
+
no `write_policy:` — instead, every declared lane-kind's required verb MUST be
|
|
341
321
|
held by at least one role, or the manifest is rejected at load.
|
|
342
322
|
|
|
343
323
|
When the `roles:` block is omitted, the default mapping applies:
|
|
@@ -348,7 +328,7 @@ When the `roles:` block is omitted, the default mapping applies:
|
|
|
348
328
|
| `agent` | `[propose, keep]` |
|
|
349
329
|
| `automation` | `[converge]` |
|
|
350
330
|
|
|
351
|
-
Wire protocol `textus/
|
|
331
|
+
Wire protocol `textus/4` is unchanged — capabilities are a manifest/semantics
|
|
352
332
|
concept and never appear on the wire.
|
|
353
333
|
|
|
354
334
|
Every write transition is authorized by **one Guard** (ADR 0031): an ordered
|
|
@@ -360,74 +340,39 @@ when the acting role holds `author`). See §5.11 for composing extra predicates
|
|
|
360
340
|
|
|
361
341
|
### 5.2 Source layer (produced entries)
|
|
362
342
|
|
|
363
|
-
Produced entries live in a `machine` lane (writable by a role holding `converge`; `automation` by default) — `artifacts` in the default scaffold. They are not authored by hand; their **data** is acquired from a declared `source:` block with
|
|
343
|
+
Produced entries live in a `machine` lane (writable by a role holding `converge`; `automation` by default) — `artifacts` in the default scaffold. They are not authored by hand; their **data** is acquired from a declared `source:` block with `from: external`. A `source:` is **acquire-only**: it produces the data the store holds; it does **not** render. Rendering is a publish concern (§5.3). Every produced entry is `kind: produced` (ADR 0095).
|
|
364
344
|
|
|
365
|
-
#### 5.2.1
|
|
345
|
+
#### 5.2.1 External source (`from: external`)
|
|
366
346
|
|
|
367
|
-
A
|
|
347
|
+
A produced entry that acquires its data via the `Textus.workflow` DSL declares `source: { from: external, command: "true", sources: [] }`. textus does **not** execute the command field; the workflow DSL block (in `.textus/workflows/**/*.rb`) runs on `drain` and returns the data to be stored.
|
|
368
348
|
|
|
369
349
|
```yaml
|
|
370
|
-
- key: artifacts.
|
|
371
|
-
kind: produced # produce-method (derived) read from source.from: derive
|
|
350
|
+
- key: artifacts.feeds.skills
|
|
372
351
|
lane: artifacts
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
pluck: [name, relationship, org]
|
|
378
|
-
sort_by: name # optional
|
|
379
|
-
limit: 1000 # default 1000, max 1000
|
|
380
|
-
format: json # one of: list, hash, yaml-list-in-md, json, markdown-table
|
|
381
|
-
on_write: async # sync | async (default async)
|
|
352
|
+
kind: produced
|
|
353
|
+
format: json
|
|
354
|
+
source: { from: external, command: "true", sources: [] }
|
|
355
|
+
publish: [{ to: docs/reference/skills.md, template: feeds/skills.erb }]
|
|
382
356
|
```
|
|
383
357
|
|
|
384
|
-
|
|
358
|
+
A matching `Textus.workflow` block in `.textus/workflows/`:
|
|
385
359
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
> **No source-level `template:` / `provenance:`.** Those are retired from `source:` (and from the entry top level). Rendering moves to a publish target (§5.3); provenance is carried in the data's `_meta` (§5.11). A manifest carrying `source: { from: template }` or an entry-level `template:`/`provenance:` is **rejected at load** with a fold hint (ADR 0094).
|
|
391
|
-
|
|
392
|
-
#### 5.2.2 External source (`from: external`)
|
|
393
|
-
|
|
394
|
-
A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `source: { from: external, ... }`. textus does **not** execute the command (consistent with §2); the external automation is responsible for writing the file. textus records `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the derived file's `_meta.generated.at` and report staleness. (Generator/build drift is dependency-based, not age-based; ADR 0085 keeps it out of the internal `freshness` scan — it is a `doctor` health check.)
|
|
395
|
-
|
|
396
|
-
```yaml
|
|
397
|
-
- key: artifacts.derived.skills
|
|
398
|
-
path: data/artifacts/derived/skills.md
|
|
399
|
-
kind: produced # produce-method (derived) read from source.from: external
|
|
400
|
-
lane: artifacts
|
|
401
|
-
owner: automation:catalog-skills
|
|
402
|
-
source:
|
|
403
|
-
from: external
|
|
404
|
-
command: "rake catalog:skills" # informational; external automation invokes it
|
|
405
|
-
sources: # dotted keys OR repo-relative paths
|
|
406
|
-
- knowledge.projects
|
|
407
|
-
- knowledge.network
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
**`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.
|
|
411
|
-
|
|
412
|
-
**`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `doctor`'s `generator_drift` output can carry a hint about how to regenerate.
|
|
413
|
-
|
|
414
|
-
**Generator-drift contract.** An entry with `source: { from: external }` is reported by `doctor`'s `generator_drift` check as drifted when:
|
|
415
|
-
- The derived file does not exist, OR
|
|
416
|
-
- `_meta.generated.at` is missing or unparseable, OR
|
|
417
|
-
- Any `sources:` element has been modified after `_meta.generated.at`.
|
|
360
|
+
```ruby
|
|
361
|
+
Textus.workflow "agentskills" do
|
|
362
|
+
match "artifacts.feeds.skills"
|
|
418
363
|
|
|
419
|
-
|
|
364
|
+
step :fetch do |_, _ctx|
|
|
365
|
+
# acquire data — return { "content" => { ... } }
|
|
366
|
+
{ "content" => { "skills" => [...], "count" => 1 } }
|
|
367
|
+
end
|
|
420
368
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
by: "rake catalog:skills"
|
|
424
|
-
at: "2026-05-25T12:00:00Z"
|
|
425
|
-
from: [knowledge.projects, knowledge.network]
|
|
369
|
+
publish
|
|
370
|
+
end
|
|
426
371
|
```
|
|
427
372
|
|
|
428
|
-
`
|
|
373
|
+
`drain` discovers all workflow files, matches each to the produced entries via `match`, runs the steps, and writes the result back to the entry's data path. `publish:` then copies or renders it to consumer paths.
|
|
429
374
|
|
|
430
|
-
|
|
375
|
+
**`sources:`** lists dotted-key prefixes or repo-relative paths whose mtimes `doctor`'s `generator_drift` check compares against `_meta.generated.at`. An empty list (`sources: []`) disables drift detection for that entry.
|
|
431
376
|
|
|
432
377
|
### 5.3 Publish layer (`publish:`)
|
|
433
378
|
|
|
@@ -440,8 +385,8 @@ The legacy *map* forms — `publish: { to: [...] }` and `publish: { tree: ... }`
|
|
|
440
385
|
|
|
441
386
|
```yaml
|
|
442
387
|
publish:
|
|
443
|
-
- { to: CLAUDE.md, template: orientation.
|
|
444
|
-
- { to: AGENTS.md, template: orientation.
|
|
388
|
+
- { to: CLAUDE.md, template: orientation.erb, inject_boot: true }
|
|
389
|
+
- { to: AGENTS.md, template: orientation.erb } # same data, its own render
|
|
445
390
|
- { to: .mcp.json } # no template → copy data verbatim
|
|
446
391
|
- { tree: skills/ } # subtree mirror (ADR 0047)
|
|
447
392
|
```
|
|
@@ -449,59 +394,53 @@ publish:
|
|
|
449
394
|
A **to-target** carries `to:` (required) and optionally `template:` / `inject_boot:`:
|
|
450
395
|
|
|
451
396
|
- **No `template:`** → publish the entry's **content**. For a structured data format (`json`/`yaml`) the content is re-serialized *without* textus's `_meta` block, so a config like `.mcp.json` stays a clean consumer file; for any other / opaque format, a literal byte-copy. (This is "publish the content," not "copy the stored envelope.")
|
|
452
|
-
- **`template:` present** → render the entry's data through the named
|
|
397
|
+
- **`template:` present** → render the entry's data through the named ERB template under `.textus/templates/` and publish the rendered bytes. One dataset can feed differently-formatted outputs by giving each to-target its own template.
|
|
453
398
|
- **`inject_boot:`** (default `false`) → merge the `textus boot` payload into the render data for *this target*. It is per-target and only meaningful alongside a `template:`.
|
|
454
399
|
|
|
455
400
|
**Published artifacts are clean content.** textus's `_meta` provenance (`from`/`reduce`, §5.12) stays in the **stored** entry and is never emitted — a verbatim copy strips it on re-serialize, a rendered template surfaces provenance only if it explicitly references `_meta`. There is no entry-level / publish `provenance:` flag (rejected at load); provenance is carried in one place, the stored data's `_meta`.
|
|
456
401
|
|
|
457
|
-
The
|
|
402
|
+
The ERB template receives the entry's `content` hash as local variables via `ERB#result_with_hash`. Templates live under `.textus/templates/` as `.erb` files. If `inject_boot: true` is set on the publish target, a `boot` variable is also available with the live orientation context.
|
|
458
403
|
|
|
459
|
-
A sentinel is written for each published file at `<store_root>/.
|
|
404
|
+
A sentinel is written for each published file at `<store_root>/.state/sentinels/<target-relative-to-repo>.textus-managed.json` (git-ignored runtime state — ADR 0070), recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store's runtime tree rather than beside the consumer file so target directories stay clean, and are regenerated by the next drain (via content-identical adoption) rather than committed. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
|
|
460
405
|
|
|
461
|
-
**Subtree mirror.** A nested entry MAY include a `{ tree: "dir" }` target (see §4). On every drain/serve pass, textus walks the entry's full stored subtree (`
|
|
406
|
+
**Subtree mirror.** A nested entry MAY include a `{ tree: "dir" }` target (see §4). On every drain/serve pass, textus walks the entry's full stored subtree (`data/<lane>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/.state/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The convergence envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `produced` array, plus a `pruned` array listing any orphaned managed files removed on this pass. Targets that would resolve outside the repo root are refused. When a `{ tree: }` target overlaps another entry's `{ to: }` target (e.g. a derived `SKILL.md` written into the mirrored dir), the mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
|
|
462
407
|
|
|
463
408
|
**Publish presence is a uniform rule across all kinds.** Absent → the entry is terminal data (consumed internally via another entry's `select`, or read via `get`). Present → emit to the listed targets, every kind through one publish path. A `from: command` entry with publish targets emits the bytes the command already wrote into the store; without targets it is a staleness-only signal.
|
|
464
409
|
|
|
465
|
-
### 5.4
|
|
466
|
-
|
|
467
|
-
Intake entries acquire their data via `source: { from: handler, ... }` — an external fetch through a registered handler. The `source:` block fully replaces the former `intake:` block; the entry's `kind:` is `produced` and the *intake* produce-method is read from `source.from: handler` (ADR 0095). Like every `source:`, it is acquire-only — a fetched feed is **data**, and if it needs rendering for a consumer that is a publish target's `template:` (§5.3), never the handler's job. textus itself makes no implicit network calls: the handler runs only when `textus drain`/`serve` or a `hook run` event re-pulls a stale entry past its `source.ttl` — a `get` never runs it (ADR 0089).
|
|
410
|
+
### 5.4 Raw lane and ingest verb
|
|
468
411
|
|
|
469
|
-
|
|
470
|
-
- key: feeds.calendar.events
|
|
471
|
-
kind: produced # produce-method (intake) read from source.from: handler
|
|
472
|
-
zone: feeds
|
|
473
|
-
source:
|
|
474
|
-
from: handler
|
|
475
|
-
handler: ical-events
|
|
476
|
-
config:
|
|
477
|
-
url: "https://calendar.google.com/.../basic.ics"
|
|
478
|
-
ttl: 6h # re-pull cadence; drain/serve re-pulls when past ttl
|
|
479
|
-
```
|
|
412
|
+
The `raw` lane (`kind: raw`) is a write-once intake lane for external source material that has not been reviewed. All three default roles (`human`, `agent`, `automation`) hold the `ingest` capability. (ADR 0116)
|
|
480
413
|
|
|
481
|
-
|
|
414
|
+
**Write-once contract** — the same key MAY NOT be written twice on the same day. A collision returns `write_forbidden`. To replace an entry, delete it and re-ingest.
|
|
482
415
|
|
|
483
|
-
|
|
416
|
+
**Key derivation** — the `ingest` verb derives a daily key: `raw.YYYY.MM.DD.<kind>-<slug>` where `YYYY.MM.DD` is the UTC date at ingest time.
|
|
484
417
|
|
|
485
|
-
|
|
418
|
+
**Three source kinds:**
|
|
486
419
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
420
|
+
| Kind | Required fields | Stored content |
|
|
421
|
+
|------|----------------|----------------|
|
|
422
|
+
| `url` | `--url` | URL reference only (`body: null`) — a bookmark, never a content fetch |
|
|
423
|
+
| `file` | `--path` | File body text — use only for genuinely valuable content |
|
|
424
|
+
| `asset` | `--path`, `--zone` | Binary copied to `assets/raw/YYYY/MM/DD/<zone>/`; inline body is null |
|
|
490
425
|
|
|
491
|
-
**
|
|
426
|
+
**`access` field** — entries MAY carry `source.access: public | private` (field is `maintained_by: human`). Set `private` for sources not safe to reproduce publicly.
|
|
492
427
|
|
|
493
|
-
**
|
|
428
|
+
**Notebook stub** — every ingest creates a `notebook.notes` stub with a backlink (`Ingested from raw.<key>`) so the agent or human can annotate the ingested material without touching the write-once record.
|
|
494
429
|
|
|
495
|
-
|
|
496
|
-
2. **Event push** — `textus hook run` invokes a handler for a specific key on an external event (the same `:resolve_handler` path), for sources that announce changes rather than waiting for the sweep.
|
|
430
|
+
**Example — URL bookmark:**
|
|
497
431
|
|
|
498
|
-
|
|
432
|
+
```sh
|
|
433
|
+
textus ingest url agentskills-io-brainstorming \
|
|
434
|
+
--url=https://agentskills.io/skills/brainstorming \
|
|
435
|
+
--label="brainstorming skill" \
|
|
436
|
+
--as=agent
|
|
437
|
+
```
|
|
499
438
|
|
|
500
|
-
|
|
439
|
+
A `get` on a raw entry is a pure read — it returns the entry as stored and never re-fetches (ADR 0089).
|
|
501
440
|
|
|
502
441
|
### 5.5 Pending / accept workflow
|
|
503
442
|
|
|
504
|
-
Proposal entries are full patches authored into the `proposals` queue
|
|
443
|
+
Proposal entries are full patches authored into the `proposals` queue lane (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 lane:
|
|
505
444
|
|
|
506
445
|
```yaml
|
|
507
446
|
---
|
|
@@ -516,9 +455,9 @@ _meta:
|
|
|
516
455
|
Proposed body content.
|
|
517
456
|
```
|
|
518
457
|
|
|
519
|
-
`proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The sibling `_meta` block and the body are the proposed new content — a proposal carries the same `{ _meta, body }` envelope shape it intends `accept` to write (ADR 0113). A proposal's `target_key` MUST resolve to a `canon`
|
|
458
|
+
`proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The sibling `_meta` block and the body are the proposed new content — a proposal carries the same `{ _meta, body }` envelope shape it intends `accept` to write (ADR 0113). A proposal's `target_key` MUST resolve to a `canon` lane; `accept` refuses any other target (`target_is_canon`, ADR 0035).
|
|
520
459
|
|
|
521
|
-
`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
|
|
460
|
+
`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 lane, 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.
|
|
522
461
|
|
|
523
462
|
### 5.6 Audit log
|
|
524
463
|
|
|
@@ -606,71 +545,7 @@ evolution:
|
|
|
606
545
|
|
|
607
546
|
**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`.
|
|
608
547
|
|
|
609
|
-
### 5.9
|
|
610
|
-
|
|
611
|
-
This section is the normative event table. For the hook-author's guide (how to define and test hooks), see [`docs/how-to/writing-hooks.md`](docs/how-to/writing-hooks.md).
|
|
612
|
-
|
|
613
|
-
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.
|
|
614
|
-
|
|
615
|
-
The subdirectory layout under `hooks/` is organizational only; the registered event and name come from the DSL call, not the file path.
|
|
616
|
-
|
|
617
|
-
#### Registration DSL
|
|
618
|
-
|
|
619
|
-
```ruby
|
|
620
|
-
# Canonical form — works for every event:
|
|
621
|
-
Textus.hook do |reg|
|
|
622
|
-
reg.on(:resolve_handler, :my_source) { |caps:, config:, args:, **| … }
|
|
623
|
-
reg.on(:transform_rows, :rank_by_recency) { |caps:, rows:, **| … }
|
|
624
|
-
reg.on(:validate, :storage_writable) { |caps:| … }
|
|
625
|
-
reg.on(:entry_written, :audit, keys: ["working.*"]) { |ctx:, key:, envelope:, **| … }
|
|
626
|
-
reg.on(:entry_published, :git_add, keys: ["derived.*"]) { |ctx:, target:, **| `git add #{target.shellescape}` }
|
|
627
|
-
end
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
`Textus.hook` is the sole entry point. The block receives the store's `Hooks::Registry`; `reg.on` is the only registration primitive.
|
|
631
|
-
|
|
632
|
-
#### Event table
|
|
633
|
-
|
|
634
|
-
| Event | Mode | Args | Return | Failure |
|
|
635
|
-
|-------------------------|---------|-----------------------------------------------------------|-----------------------|---------------|
|
|
636
|
-
| `:resolve_handler` | rpc | caps:, config:, args: | {_meta:, body:} | aborts op |
|
|
637
|
-
| `:transform_rows` | rpc | caps:, rows:, config: | rows array | aborts op |
|
|
638
|
-
| `:validate` | rpc | caps: | issues array | aborts doctor |
|
|
639
|
-
| `:entry_written` | pubsub | ctx:, key:, envelope: | (discarded) | logged |
|
|
640
|
-
| `:entry_deleted` | pubsub | ctx:, key: | (discarded) | logged |
|
|
641
|
-
| `:entry_fetched` | pubsub | ctx:, key:, envelope:, change: | (discarded) | logged |
|
|
642
|
-
| `:entry_produced` | pubsub | ctx:, key:, envelope:, sources: | (discarded) | logged |
|
|
643
|
-
| `:proposal_accepted` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
|
|
644
|
-
| `:entry_published` | pubsub | ctx:, key:, envelope:, source:, target: | (discarded) | logged |
|
|
645
|
-
| `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
|
|
646
|
-
| `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
|
|
647
|
-
| `:store_loaded` | pubsub | ctx: | (discarded) | logged |
|
|
648
|
-
| `:session_opened` | pubsub | ctx:, role:, cursor: | (discarded) | logged |
|
|
649
|
-
| `:entry_fetch_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
|
|
650
|
-
| `:entry_fetch_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
|
|
651
|
-
|
|
652
|
-
The two `:entry_fetch_*` lifecycle events report the progress and failures of intake fetches during `drain`/`serve` / `hook run`.
|
|
653
|
-
|
|
654
|
-
**`:entry_fetch_started`** fires immediately before an intake handler is invoked. `mode:` is `"refresh"`.
|
|
655
|
-
|
|
656
|
-
**`:entry_fetch_failed`** fires when an intake handler raises. `error_class:` is the exception class name string; `error_message:` is `e.message`.
|
|
657
|
-
|
|
658
|
-
**Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
|
|
659
|
-
|
|
660
|
-
- **RPC hooks** (`rpc` mode) receive `caps:` — a `Textus::Container`. Event-specific kwargs (`config:`, `args:`, `rows:`) follow in the stable order shown in the table above.
|
|
661
|
-
- **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads — `freshness` is the Ruby-only internal lifecycle scan, ADR 0085, not a public verb), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
|
|
662
|
-
|
|
663
|
-
Declaring `store:` instead of `caps:` in an RPC callable will pass registration but raise `UsageError` at call time (`Hooks::RpcRegistry#invoke` rejects `store:` — there is no shim).
|
|
664
|
-
|
|
665
|
-
The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry. For `:session_opened`, no key — it fires once per MCP connection at `initialize` with the connection's resolved `role:` and boot `cursor:` (ADR 0075); distinct from `:store_loaded`, which fires once per process at `Store#initialize` under the default role.
|
|
666
|
-
|
|
667
|
-
**RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`source.handler: NAME` for `:resolve_handler`, `source.transform: NAME` for `:transform_rows`). Failure or timeout aborts the calling operation.
|
|
668
|
-
|
|
669
|
-
**Pub-sub mode** — zero or more handlers per event. All matching handlers fire. The `keys:` option restricts a handler to keys matching one of the given globs (`File.fnmatch?` rules). Absence of `keys:` fires on every event of that type. Handler failures and 2s timeouts are logged to `audit.log` as `event_error` rows; they NEVER abort the triggering operation.
|
|
670
|
-
|
|
671
|
-
Each handler runs under `Timeout.timeout(2)`.
|
|
672
|
-
|
|
673
|
-
### 5.10 Rules
|
|
548
|
+
### 5.9 Rules
|
|
674
549
|
|
|
675
550
|
A manifest MAY declare a top-level `rules:` block — a list of rule blocks matched against entry keys by glob. Each block carries one or more slots:
|
|
676
551
|
|
|
@@ -703,7 +578,7 @@ The `retention:` slot handles age-based GC only. Write-trigger strategy for deri
|
|
|
703
578
|
|
|
704
579
|
**Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key — lean effective `{retention, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
|
|
705
580
|
|
|
706
|
-
### 5.
|
|
581
|
+
### 5.10 Storage formats
|
|
707
582
|
|
|
708
583
|
An entry's `format:` selects a storage strategy. All strategies expose the same `parse(bytes) → {_meta, body, content}` and `serialize(meta:, body:, content:) → bytes` contract. The store, audit, etag, and projection layers operate on the parsed shape; only (de)serialization differs.
|
|
709
584
|
|
|
@@ -771,7 +646,7 @@ The frontmatter `name:` field, when present, must match the file's basename (wit
|
|
|
771
646
|
- Existing files without a uid continue to work. The envelope shows `"uid": null` until a put mints one.
|
|
772
647
|
- `text` entries have no metadata channel and therefore no uid; their envelope always shows `"uid": null`.
|
|
773
648
|
|
|
774
|
-
Entries in a `
|
|
649
|
+
Entries in a `produced` lane 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.
|
|
775
650
|
|
|
776
651
|
## 8. Envelope (the wire format)
|
|
777
652
|
|
|
@@ -779,11 +654,11 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
779
654
|
|
|
780
655
|
```json
|
|
781
656
|
{
|
|
782
|
-
"protocol": "textus/
|
|
657
|
+
"protocol": "textus/4",
|
|
783
658
|
"key": "knowledge.network.org.jane",
|
|
784
|
-
"
|
|
659
|
+
"lane": "knowledge",
|
|
785
660
|
"owner": "human:network",
|
|
786
|
-
"path": "/absolute/path/to/.textus/
|
|
661
|
+
"path": "/absolute/path/to/.textus/data/knowledge/network/org/jane.md",
|
|
787
662
|
"format": "markdown",
|
|
788
663
|
"_meta": { "name": "jane", "relationship": "peer", "org": "acme" },
|
|
789
664
|
"body": "Short body in Markdown.\n",
|
|
@@ -797,9 +672,9 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
797
672
|
```
|
|
798
673
|
|
|
799
674
|
**Field rules:**
|
|
800
|
-
- `protocol` MUST be the exact string `textus/
|
|
675
|
+
- `protocol` MUST be the exact string `textus/4`.
|
|
801
676
|
- `key` MUST be the canonical resolved key.
|
|
802
|
-
- `
|
|
677
|
+
- `lane` MUST be one of the lanes declared in the manifest (`knowledge`, `notebook`, `feeds`, `proposals`, `artifacts` in the default Setup-1 scaffold).
|
|
803
678
|
- `path` MUST be an absolute filesystem path.
|
|
804
679
|
- `format` MUST be one of `markdown`, `json`, `yaml`, `text` (§5.12). Absent envelopes are treated as `markdown` for back-compat.
|
|
805
680
|
- `body` is the raw on-disk bytes as a UTF-8 string for every format.
|
|
@@ -807,7 +682,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
807
682
|
- `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
|
|
808
683
|
- `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
|
|
809
684
|
- `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
|
|
810
|
-
- `stale` is `true` when the entry's `source.ttl` has elapsed and the entry has not yet been re-
|
|
685
|
+
- `stale` is `true` when the entry's `source.ttl` has elapsed and the entry has not yet been re-materialised; `false` otherwise. Only populated for produced entries with a declared `ttl`; always `false` for other entries.
|
|
811
686
|
- `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`.
|
|
812
687
|
- `fetching` is `true` when a background re-pull is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
|
|
813
688
|
|
|
@@ -817,12 +692,12 @@ Errors use a distinct envelope:
|
|
|
817
692
|
|
|
818
693
|
```json
|
|
819
694
|
{
|
|
820
|
-
"protocol": "textus/
|
|
695
|
+
"protocol": "textus/4",
|
|
821
696
|
"ok": false,
|
|
822
697
|
"code": "write_forbidden",
|
|
823
|
-
"message": "writing 'knowledge.identity.self' (
|
|
698
|
+
"message": "writing 'knowledge.identity.self' (lane 'knowledge') needs capability 'author'",
|
|
824
699
|
"hint": "held by: human; pass --as=<role>",
|
|
825
|
-
"details": { "key": "knowledge.identity.self", "
|
|
700
|
+
"details": { "key": "knowledge.identity.self", "lane": "knowledge", "verb": "author", "holders": ["human"] }
|
|
826
701
|
}
|
|
827
702
|
```
|
|
828
703
|
|
|
@@ -833,7 +708,7 @@ Errors use a distinct envelope:
|
|
|
833
708
|
| `unknown_key` | Key does not resolve | 1 |
|
|
834
709
|
| `bad_frontmatter` | YAML parse failed or `name:` mismatch | 1 |
|
|
835
710
|
| `schema_violation` | Required field missing or wrong type | 1 |
|
|
836
|
-
| `write_forbidden` | Resolved role lacks the capability the
|
|
711
|
+
| `write_forbidden` | Resolved role lacks the capability the lane-kind requires | 1 |
|
|
837
712
|
| `etag_mismatch` | Concurrent write detected | 1 |
|
|
838
713
|
| `io_error` | Filesystem failure | 64 |
|
|
839
714
|
| `usage` | CLI argument error | 2 |
|
|
@@ -842,15 +717,15 @@ Errors use a distinct envelope:
|
|
|
842
717
|
|
|
843
718
|
The reference binary is `textus`. Conforming implementations MAY use any binary name; the protocol is in the JSON.
|
|
844
719
|
|
|
845
|
-
All verbs accept `--output=json` and emit a canonical envelope (success or error). Write verbs require `--as=<role>`; the role must satisfy the target
|
|
720
|
+
All verbs accept `--output=json` and emit a canonical envelope (success or error). Write verbs require `--as=<role>`; the role must satisfy the target lane's write gate (§5). The per-entry `format:` field in the manifest is unchanged — `--output` controls only the CLI envelope rendering.
|
|
846
721
|
|
|
847
722
|
| Verb | Reads / writes | Role required |
|
|
848
723
|
|---|---|---|
|
|
849
|
-
| `list [--prefix=K] [--
|
|
724
|
+
| `list [--prefix=K] [--lane=Z]` | read | any |
|
|
850
725
|
| `where K` | read | any |
|
|
851
726
|
| `get K` | read (a pure on-disk read annotated with a freshness verdict; never refreshes — ADR 0089) | any |
|
|
852
727
|
| `schema show K` | read | any |
|
|
853
|
-
| `audit [--key=K] [--
|
|
728
|
+
| `audit [--key=K] [--lane=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
|
|
854
729
|
| `blame KEY` | read | any |
|
|
855
730
|
| `rule list` / `rule explain KEY` | read | any |
|
|
856
731
|
| `deps K` / `rdeps K` | read | any |
|
|
@@ -860,17 +735,17 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
860
735
|
| `doctor [--check=NAME[,NAME]] [--output=json]` | read | any |
|
|
861
736
|
| `boot [--output=json]` | read | any |
|
|
862
737
|
| `pulse [--since=N]` | read | any |
|
|
863
|
-
| `put K --stdin --as=R` | write (stores the stdin JSON; runs no handler — ADR 0089) | per
|
|
738
|
+
| `put K --stdin --as=R` | write (stores the stdin JSON; runs no handler — ADR 0089) | per lane |
|
|
864
739
|
| `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
|
|
865
|
-
| `key delete K --if-etag=E --as=R` | write | per
|
|
866
|
-
| `drain [--prefix=K] [--
|
|
740
|
+
| `key delete K --if-etag=E --as=R` | write | per lane |
|
|
741
|
+
| `drain [--prefix=K] [--lane=Z]` | write | `converge`-holder (typically `automation`) |
|
|
867
742
|
| `serve [--poll=SECS]` | write (long-lived daemon) | `converge`-holder (typically `automation`) |
|
|
868
743
|
| `jobs [--state=ready\|leased\|done\|failed] [--action=retry\|purge] [--job-id=ID]` | read | any |
|
|
869
744
|
| `accept K --as=human` | write | `author`-holder (typically `human`) |
|
|
870
745
|
| `reject K --as=human` | write | `author`-holder (typically `human`) |
|
|
871
746
|
| `init` | write | `human` |
|
|
872
747
|
| `schema {show,init,diff,migrate}` | read/write | `human` for writes |
|
|
873
|
-
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per
|
|
748
|
+
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per lane (same-lane only) |
|
|
874
749
|
| `key uid K` | read | any |
|
|
875
750
|
|
|
876
751
|
**`textus boot` envelope extras.** In addition to lanes, entries, hooks, write flows, and the `cli_verbs` catalog, the boot envelope includes an `agent_quickstart` block synthesized from the manifest's role capabilities:
|
|
@@ -905,7 +780,7 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
|
|
|
905
780
|
}
|
|
906
781
|
```
|
|
907
782
|
|
|
908
|
-
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `pending_review` lists all keys in the queue
|
|
783
|
+
`cursor` is the new high-water mark; pass it as `--since` on the next call. `changed` is sourced from `audit --seq-since`. `pending_review` lists all keys in the `proposals` queue lane. `contract_etag` is the `sha256:`-prefixed composite content hash of the contract — the manifest plus hooks and schemas (ADR 0074, via ADR 0025) — for cheap change-detection. `index_etag` is the etag of the `artifacts.index` catalog file, or `null` when it does not exist — agents use this to detect when the catalog has been rebuilt. When `--since` is below the oldest available seq (due to audit log rotation), pulse returns `CursorExpired`.
|
|
909
784
|
|
|
910
785
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
911
786
|
|
|
@@ -919,38 +794,36 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
|
|
|
919
794
|
|
|
920
795
|
The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) against each intake entry's `source.ttl`. ADR 0085 removed the standalone `freshness` verb that used to render these rows; the scan is now Ruby-only (consumed by `pulse` and the hook context), and human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `source.ttl` and retention policy). `textus drain` enqueues the convergence jobs — produce every in-scope derived entry, re-pull every stale intake entry, and a retention sweep — then drains the queue to empty (§5.11). Convergence is async-only (ADR 0110): there is no `--dry-run`.
|
|
921
796
|
|
|
922
|
-
`textus accept K --as=human` promotes a pending entry into its target
|
|
797
|
+
`textus accept K --as=human` promotes a pending entry into its target lane: 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`.
|
|
923
798
|
|
|
924
|
-
`textus drain [--prefix=K] [--
|
|
799
|
+
`textus drain [--prefix=K] [--lane=Z]` is the manual converge-and-exit pass (ADR 0093, ADR 0110). It seeds a closed allow-list of jobs into the durable file-backed queue (`Ports::Queue` under `.textus/.state/queue/`) and runs a worker until the queue is empty: a **`materialize`** job per in-scope derived / publish entry (always rebuild — pure/idempotent, unchanged sources write nothing; nested `{ tree: }` targets included), a **`re-pull`** job per intake entry past its `source.ttl`, and a single **`sweep`** job for the destructive `retention:` GC (§5.11). Authority is frozen at enqueue: `materialize`/`re-pull` self-elevate inside `Produce::Engine` to the manifest's `converge`-capable actor (`automation` by default) — materialization is a pure function of already-accepted canon and grants no authority over content — while `sweep` runs as the **caller** (gated as the caller's own `key_delete` authority), never self-elevating. Drain is single-pass and **serial**: each produce job self-acquires the non-reentrant build lock, so a held lock is a graceful soft-miss. `drain` returns `{ ok, completed, failed, health }` and exits non-zero if any job dead-lettered; per-key produce failures surface as `:produce_failed` events. There is no `--dry-run` (materialization is async-only). `textus serve` is the same worker as a long-lived daemon, whose `Scheduler` seeds TTL re-pull + sweep each tick; `textus jobs` inspects/retries/purges the queue. In day-to-day use derived entries stay fresh **reactively** — a canon write enqueues a `materialize` job for each dependent derived entry (the reactive scope is "converge narrowed to rdeps ∩ derived"), processed by a running `serve` or the next `drain` — so `drain` is the on-demand / CI catch-all, not a step in the normal write loop.
|
|
925
800
|
|
|
926
|
-
`textus init` scaffolds a fresh `.textus/` tree (manifest,
|
|
801
|
+
`textus init` scaffolds a fresh `.textus/` tree (manifest, lanes, schemas, audit log) under the current directory with a default manifest. Customize by editing `.textus/manifest.yaml` after init.
|
|
927
802
|
|
|
928
803
|
`textus schema show K` prints the schema for entry `K`. `textus schema init NAME` writes a stub schema. `textus schema diff NAME` compares the on-disk schema against entries that claim it and prints the deltas. `textus schema migrate NAME --rename=OLD:NEW` rewrites the `_meta` key `OLD` to `NEW` across every entry that uses the named schema, in a single transactional sweep that logs each touched file.
|
|
929
804
|
|
|
930
805
|
## 10. ETag semantics
|
|
931
806
|
|
|
932
807
|
The etag is `sha256:<lowercase-hex-digest-of-raw-file-bytes>`. Computed after any normalization (trailing newline on write, UTF-8 encoding). Both reads and successful writes return the current etag; passing it back in `if_etag` enforces optimistic concurrency.
|
|
933
|
-
|
|
934
808
|
## 10.1 Errors carry hints
|
|
935
809
|
|
|
936
810
|
Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hint is a single short string suggesting the next action — the file to edit, the role to pass, the command to run. Errors in the wire envelope include the hint as a top-level `hint:` field when present. The CLI prints failures to stderr as `code: message` followed by ` → hint` (when a hint exists), in addition to the JSON envelope on stdout. Hints are advisory: implementations MAY omit or rephrase them without breaking conformance.
|
|
937
811
|
|
|
938
812
|
## 10.2 `textus doctor`
|
|
939
813
|
|
|
940
|
-
`textus doctor` returns a health-check envelope: `{ "protocol": "textus/
|
|
814
|
+
`textus doctor` returns a health-check envelope: `{ "protocol": "textus/4", "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`, `intake_registration`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `rule_ambiguity`, `handler_permit`, `fetch_locks`, `proposal_targets`, `publish.tree_index_overlap`, `generator_drift`. Additional registered `:validate` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
|
|
941
815
|
|
|
942
816
|
## 11. Versioning
|
|
943
817
|
|
|
944
|
-
- The current wire string is `textus/
|
|
945
|
-
- Backward-compatible additions (new fields, new error codes, new schema types) MAY be made under `textus/
|
|
946
|
-
- Breaking changes (renamed/removed envelope fields,
|
|
818
|
+
- The current wire string is `textus/4`.
|
|
819
|
+
- Backward-compatible additions (new fields, new error codes, new schema types) MAY be made under `textus/4`.
|
|
820
|
+
- Breaking changes (renamed/removed envelope fields, lane semantics, key grammar) require a new wire string `textus/4`.
|
|
947
821
|
- Implementations MUST reject envelopes whose `protocol` they do not recognize.
|
|
948
822
|
|
|
949
|
-
The reference Ruby gem follows semver independently and speaks `textus/
|
|
950
|
-
|
|
823
|
+
The reference Ruby gem follows semver independently and speaks `textus/4`.
|
|
951
824
|
## 11.1 Agent integration
|
|
952
825
|
|
|
953
|
-
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
|
|
826
|
+
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 lanes, and its propose lane. 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.
|
|
954
827
|
|
|
955
828
|
For the full boot → pulse loop with pseudocode and cursor-expiry handling, see [`docs/how-to/agents-mcp.md`](docs/how-to/agents-mcp.md).
|
|
956
829
|
|
|
@@ -959,25 +832,25 @@ For the full boot → pulse loop with pseudocode and cursor-expiry handling, see
|
|
|
959
832
|
A conformant implementation MUST pass these fixtures (the reference test suite ships a YAML file listing inputs and expected envelopes):
|
|
960
833
|
|
|
961
834
|
**Fixture A — Resolve and read:**
|
|
962
|
-
Given a manifest with `working.network.org` → `working/network/org` (nested), schema `person`, and a file `.textus/
|
|
835
|
+
Given a manifest with `working.network.org` → `working/network/org` (nested), schema `person`, and a file `.textus/data/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.
|
|
963
836
|
|
|
964
837
|
**Fixture B — Role gate on write:**
|
|
965
|
-
Given a manifest entry where `key: identity.self` lives in the `identity`
|
|
838
|
+
Given a manifest entry where `key: identity.self` lives in the `identity` lane (`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.
|
|
966
839
|
|
|
967
840
|
**Fixture C — Schema violation:**
|
|
968
841
|
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.
|
|
969
842
|
|
|
970
843
|
**Fixture D — Staleness detection:**
|
|
971
|
-
Given a manifest entry `
|
|
844
|
+
Given a manifest entry `artifacts.feeds` with `kind: produced` and a `retention: { ttl: 1h }` rule, and an envelope on disk whose `_meta.last_fetched_at` is older than `now - ttl`, `textus pulse --output=json` lists `artifacts.feeds` in its `stale` array (the lifecycle scan classifies it `expired`). The scan is pure: producing this verdict does NOT trigger a re-materialise.
|
|
972
845
|
|
|
973
|
-
**Fixture E —
|
|
974
|
-
Given a manifest entry `artifacts.
|
|
846
|
+
**Fixture E — Workflow produce:**
|
|
847
|
+
Given a manifest entry `artifacts.feeds.skills` with `kind: produced` and `source: { from: external, command: "true", sources: [] }` and a matching `Textus.workflow` block, `textus drain --prefix=artifacts.feeds.skills` produces the entry's **data** on disk (serialized per `format:`) matching the workflow's returned content. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so re-running with unchanged sources reproduces it byte-for-byte and writes nothing.
|
|
975
848
|
|
|
976
|
-
**Fixture F —
|
|
977
|
-
Given a produced entry with a to-target `{ to:, template: <name> }`, `textus drain` renders the entry's stored data through the template and emits a file whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization). Two to-targets with different templates produce different bytes from the one entry.
|
|
849
|
+
**Fixture F — ERB render at publish:**
|
|
850
|
+
Given a produced entry with a to-target `{ to:, template: <name> }`, `textus drain` renders the entry's stored data through the named ERB template (under `.textus/templates/`) and emits a file whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization). Two to-targets with different templates produce different bytes from the one entry.
|
|
978
851
|
|
|
979
852
|
**Fixture G — Copy publish:**
|
|
980
|
-
Given a manifest entry with a templateless to-target `publish: [{ to: <path> }]`, a successful `textus drain` for that entry leaves a plain file at `<path>` whose contents are the entry's content re-serialized without `_meta` (byte-identical to a clean consumer config), accompanied by a sentinel at `.textus/.
|
|
853
|
+
Given a manifest entry with a templateless to-target `publish: [{ to: <path> }]`, a successful `textus drain` for that entry leaves a plain file at `<path>` whose contents are the entry's content re-serialized without `_meta` (byte-identical to a clean consumer config), accompanied by a sentinel at `.textus/.state/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `drain` is idempotent.
|
|
981
854
|
|
|
982
855
|
**Fixture H — Audit log format:**
|
|
983
856
|
Every successful write verb (`put`, `key_delete`, `key_mv`, `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). Convergence (`drain`/`serve`) writes through these same verbs (`put` for a produced entry, `key_delete` for a swept one), so it appends per the underlying write, not under a distinct `drain` verb. No write produces zero or multiple lines per key.
|
|
@@ -989,49 +862,20 @@ Given a proposal entry `proposals.knowledge.self.patch` proposing a change to `k
|
|
|
989
862
|
|
|
990
863
|
- **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.
|
|
991
864
|
|
|
992
|
-
- **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/
|
|
865
|
+
- **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/4` 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.
|
|
993
866
|
|
|
994
867
|
- **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.
|
|
995
868
|
|
|
996
869
|
- **Why not Notion / Coda?** Closed, hosted, lossy export. textus is local-first, plain-files, diffable in git.
|
|
997
870
|
|
|
998
|
-
- **Why not JSON Schema for the schemas?** Considered. Bespoke YAML chosen for v1: simpler implementation, lighter dependency footprint, matches the reference impl's house language. JSON Schema MAY be added as an alternate schema-language adapter in a future minor revision without breaking `textus/
|
|
871
|
+
- **Why not JSON Schema for the schemas?** Considered. Bespoke YAML chosen for v1: simpler implementation, lighter dependency footprint, matches the reference impl's house language. JSON Schema MAY be added as an alternate schema-language adapter in a future minor revision without breaking `textus/4`.
|
|
999
872
|
|
|
1000
873
|
- **Why not a database (SQLite, kv store)?** textus's whole point is that the storage is plain files agents and humans both read. A binary store loses git-diff, grep, and editor support.
|
|
1001
874
|
|
|
1002
875
|
- **Why not vector embeddings?** Different problem. textus is for facts agents act on deterministically; embeddings are for fuzzy retrieval. They compose — index a textus tree into a vector store if you need both.
|
|
1003
|
-
|
|
1004
876
|
## 13.1 Layered architecture (internal)
|
|
1005
877
|
|
|
1006
878
|
Textus internals are organized as one-way layers — **Surfaces** (`surfaces/cli/`, `surfaces/mcp/`) → **Contract** (`contract/`) → **Dispatch** (`dispatch/`) → **Manifest + Core + Ports + Step** (domain and adapters). Each layer imports only from layers to its right. Plugin authors touch only the Step DSL and the manifest YAML; the layering is internal and may evolve.
|
|
1007
879
|
|
|
1008
880
|
See [`docs/architecture/README.md`](docs/architecture/README.md) for an ASCII diagram and the full read-path walkthrough.
|
|
1009
881
|
|
|
1010
|
-
## 14. Open questions (v3.x scope)
|
|
1011
|
-
|
|
1012
|
-
- **Locking on `put`:** the reference impl uses sha256 etags. Should the spec also define a file-lock fallback for systems where read-before-write is racy?
|
|
1013
|
-
- **Schema imports:** can one schema reference another (`type: $ref: person`)?
|
|
1014
|
-
- **Internationalization:** non-ASCII in keys? Spec currently restricts segments to `[a-z0-9][a-z0-9-]*`. Revisit if community wants Unicode.
|
|
1015
|
-
- **Generated content in `derived/`:** the spec says `schema: null` is allowed, but should there be a separate marker (`generated: true`) for clarity?
|
|
1016
|
-
|
|
1017
|
-
## 15. Implementation checklist
|
|
1018
|
-
|
|
1019
|
-
A `textus/3` implementation MUST:
|
|
1020
|
-
|
|
1021
|
-
- [ ] Parse `.textus/manifest.yaml` and accept `version: textus/3`.
|
|
1022
|
-
- [ ] Resolve keys via longest-prefix match against manifest entries.
|
|
1023
|
-
- [ ] Read `_meta` + body from `.md` files; validate against the named schema.
|
|
1024
|
-
- [ ] Read `_meta` from the top-level `_meta` hash in `.json` / `.yaml` files; validate against the named schema.
|
|
1025
|
-
- [ ] Compute `sha256:<hex>` etags over raw file bytes.
|
|
1026
|
-
- [ ] Refuse writes whose resolved role lacks the capability the target zone-kind requires with `write_forbidden`.
|
|
1027
|
-
- [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
|
|
1028
|
-
- [ ] Use the error codes in §8 and the exit-code table.
|
|
1029
|
-
- [ ] Implement the lifecycle scan behind `pulse` (`stale`/`next_due_at`) and the hook context per §5.4, §5.11, and §9, walking each intake entry, comparing `now - last_fetched_at` against `source.ttl`, and reporting `fresh|expired|no_policy` without invoking any re-pull. (ADR 0085: a Ruby-only internal scan — there is no public `freshness` verb.)
|
|
1030
|
-
- [ ] Pass the conformance fixtures A–I in §12.
|
|
1031
|
-
|
|
1032
|
-
A `textus/3` implementation MAY:
|
|
1033
|
-
|
|
1034
|
-
- Add additional CLI verbs (e.g. `move`, vendor-specific reporters) beyond the current set in §9.
|
|
1035
|
-
- Provide alternate output formats (`--output=yaml`, `--output=table`) for human use.
|
|
1036
|
-
- Support additional schema field types beyond §6, marked as `vendor:<name>` extensions.
|
|
1037
|
-
|