textus 0.43.2 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +56 -29
  4. data/SPEC.md +24 -22
  5. data/docs/architecture/README.md +32 -32
  6. data/docs/reference/conventions.md +8 -9
  7. data/lib/textus/boot.rb +4 -4
  8. data/lib/textus/builder/pipeline.rb +11 -42
  9. data/lib/textus/builder/renderer/markdown.rb +4 -8
  10. data/lib/textus/cli/group/fetch.rb +2 -2
  11. data/lib/textus/cli/group.rb +1 -0
  12. data/lib/textus/cli/runner.rb +187 -0
  13. data/lib/textus/cli/verb/build.rb +4 -4
  14. data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
  15. data/lib/textus/cli/verb/get.rb +6 -5
  16. data/lib/textus/cli/verb/put.rb +3 -3
  17. data/lib/textus/cli/verb.rb +3 -0
  18. data/lib/textus/cli.rb +37 -3
  19. data/lib/textus/container.rb +3 -15
  20. data/lib/textus/contract/around.rb +29 -0
  21. data/lib/textus/contract/binder.rb +88 -0
  22. data/lib/textus/contract/resources/cursor.rb +26 -0
  23. data/lib/textus/contract/sources.rb +39 -0
  24. data/lib/textus/contract/view.rb +15 -0
  25. data/lib/textus/contract.rb +68 -8
  26. data/lib/textus/dispatcher.rb +6 -6
  27. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  28. data/lib/textus/doctor/check/sentinels.rb +1 -1
  29. data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
  30. data/lib/textus/envelope/io/writer.rb +34 -0
  31. data/lib/textus/hooks/context.rb +24 -2
  32. data/lib/textus/layout.rb +8 -0
  33. data/lib/textus/maintenance/key_delete_prefix.rb +8 -5
  34. data/lib/textus/maintenance/key_mv_prefix.rb +18 -6
  35. data/lib/textus/maintenance/migrate.rb +14 -10
  36. data/lib/textus/maintenance/rule_lint.rb +5 -4
  37. data/lib/textus/maintenance/zone_mv.rb +9 -6
  38. data/lib/textus/manifest/entry/base.rb +1 -1
  39. data/lib/textus/mcp/catalog.rb +6 -33
  40. data/lib/textus/ports/publisher.rb +3 -2
  41. data/lib/textus/ports/sentinel_store.rb +8 -7
  42. data/lib/textus/projection.rb +6 -5
  43. data/lib/textus/read/audit.rb +19 -0
  44. data/lib/textus/read/blame.rb +11 -1
  45. data/lib/textus/read/boot.rb +1 -1
  46. data/lib/textus/read/capabilities.rb +70 -0
  47. data/lib/textus/read/deps.rb +15 -1
  48. data/lib/textus/read/doctor.rb +8 -0
  49. data/lib/textus/read/freshness.rb +10 -0
  50. data/lib/textus/read/get.rb +87 -22
  51. data/lib/textus/read/list.rb +2 -1
  52. data/lib/textus/read/published.rb +7 -0
  53. data/lib/textus/read/pulse.rb +2 -1
  54. data/lib/textus/read/rdeps.rb +14 -0
  55. data/lib/textus/read/rule_explain.rb +84 -0
  56. data/lib/textus/read/rule_list.rb +39 -0
  57. data/lib/textus/read/schema_envelope.rb +3 -2
  58. data/lib/textus/read/uid.rb +9 -0
  59. data/lib/textus/read/where.rb +8 -0
  60. data/lib/textus/role_scope.rb +34 -6
  61. data/lib/textus/schema/tools.rb +12 -3
  62. data/lib/textus/store.rb +47 -24
  63. data/lib/textus/version.rb +1 -1
  64. data/lib/textus/write/accept.rb +8 -0
  65. data/lib/textus/write/{publish.rb → build.rb} +16 -7
  66. data/lib/textus/write/delete.rb +13 -0
  67. data/lib/textus/write/fetch_all.rb +2 -1
  68. data/lib/textus/write/fetch_orchestrator.rb +1 -1
  69. data/lib/textus/write/fetch_worker.rb +2 -2
  70. data/lib/textus/write/mv.rb +16 -0
  71. data/lib/textus/write/propose.rb +8 -3
  72. data/lib/textus/write/put.rb +3 -3
  73. data/lib/textus/write/reject.rb +8 -0
  74. data/lib/textus/write/retention_sweep.rb +9 -0
  75. metadata +12 -29
  76. data/lib/textus/cli/verb/accept.rb +0 -16
  77. data/lib/textus/cli/verb/audit.rb +0 -34
  78. data/lib/textus/cli/verb/blame.rb +0 -17
  79. data/lib/textus/cli/verb/delete.rb +0 -17
  80. data/lib/textus/cli/verb/deps.rb +0 -14
  81. data/lib/textus/cli/verb/freshness.rb +0 -17
  82. data/lib/textus/cli/verb/key_delete.rb +0 -24
  83. data/lib/textus/cli/verb/list.rb +0 -16
  84. data/lib/textus/cli/verb/migrate.rb +0 -18
  85. data/lib/textus/cli/verb/mv.rb +0 -27
  86. data/lib/textus/cli/verb/propose.rb +0 -28
  87. data/lib/textus/cli/verb/published.rb +0 -13
  88. data/lib/textus/cli/verb/pulse.rb +0 -26
  89. data/lib/textus/cli/verb/rdeps.rb +0 -14
  90. data/lib/textus/cli/verb/reject.rb +0 -16
  91. data/lib/textus/cli/verb/retain.rb +0 -19
  92. data/lib/textus/cli/verb/rule_explain.rb +0 -16
  93. data/lib/textus/cli/verb/rule_lint.rb +0 -18
  94. data/lib/textus/cli/verb/rule_list.rb +0 -29
  95. data/lib/textus/cli/verb/schema.rb +0 -15
  96. data/lib/textus/cli/verb/uid.rb +0 -15
  97. data/lib/textus/cli/verb/where.rb +0 -14
  98. data/lib/textus/cli/verb/zone_mv.rb +0 -19
  99. data/lib/textus/read/get_or_fetch.rb +0 -69
  100. data/lib/textus/read/policy_explain.rb +0 -46
  101. data/lib/textus/read/rules.rb +0 -25
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d352b5ae1e1274a454c6488497c8bad60659b9c4f70ceb86da822f883b1559c5
4
- data.tar.gz: fca73540d82d4a0e7ae9d527bdae43451c8a13a89f0da486e049232754a6b6d4
3
+ metadata.gz: cd3d64e647d112827056119d45576eda3b7f6ba95446f7a08522d674fb496d2c
4
+ data.tar.gz: b8c7c4d86eccd1469bdd024732dc0321fb64d837f59451efe1280fd7ae8d96e2
5
5
  SHA512:
6
- metadata.gz: 7559e8f9b1c49a2ddbf0a3bf83c5653fa951f3f575a53113274a3b1f857fabfb709649720d6137963f9b411aa87b1178f296ae4a9f907963250920e28a77880e
7
- data.tar.gz: 60dea75e32ccdae818a6da8a4303ff7e14fd248e4230e0b7f54af45c24ea9539eaf0103707820e006e1e0ed925c030179b8e3483eb889a5120eb67afd030e9f7
6
+ metadata.gz: 10096cbffbd633d18a915744ddc4e70b7324ad955525a76fc9fb4b95b32ab8b0781d566e10d1fc6695dd5a795b4721926859030e4c86e661bdef1728304b62aa
7
+ data.tar.gz: d393eee10185c057a7fe727dbb9abb572761e12bcec4b5253cda9a5367d5cd3684850e87432079cd71296f2ff917bd4e1a3e1be5b6484cc7be5ff3c33b4611a0
data/CHANGELOG.md CHANGED
@@ -9,6 +9,76 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.46.0 — 2026-06-03 — Container is the single source of truth
13
+
14
+ No `textus/3` wire-format change. Internal refactor of the composition root. The 7-field capability set (`manifest, file_store, schemas, root, audit_log, events, rpc`) was previously spelled out four times — `Container`'s `Data.define`, `Store`'s ivar assignments, `Store`'s `attr_reader`s, and `Container.from_store`. It now lives in exactly one place.
15
+
16
+ ### Changed
17
+
18
+ - **`Store` builds its `Container` once and derives its readers from it.** `Store#initialize` constructs the `Container` directly (`build_container`); the public accessors (`store.manifest`, `store.root`, …) are generated from `Container.members`, so adding a capability to the `Data.define` auto-exposes it on `Store`. Hook wiring is extracted into `bootstrap_hooks`.
19
+ - **`Store.discover` decomposed.** The upward directory walk is extracted into `ascend_for_store`, and the duplicated `.textus`/`manifest.yaml` existence check into a shared `store_dir?` predicate.
20
+
21
+ ### Breaking (pre-1.0)
22
+
23
+ - **`Container.from_store` is removed.** It built a fresh `Container` by copying the seven accessors off a `Store`. Use `store.container` instead (built once, memoized). Specs that swapped the event bus post-construction via `store.instance_variable_set(:@events, …)` now inject explicitly through the immutable `Container`'s `#with` (e.g. `container.with(events: probe)`). Retires the `from_store` idiom described in [ADR 0016](docs/architecture/decisions/0016-application-ports-value.md) and [ADR 0020](docs/architecture/decisions/0020-capability-records.md).
24
+
25
+ ## 0.45.1 — 2026-06-03 — Single-path lifecycle: kill the last dual-paths ([ADR 0069](docs/architecture/decisions/0069-single-path-lifecycle.md))
26
+
27
+ No `textus/3` wire-format change. Finishes the 0.45.0 lifecycle (ADRs 0066–0068): the request path was single-path in shape but still carried four residual dual-paths. This removes all four so the lifecycle — `normalize → bind (always validate) → dispatch (+around) → view (self-shaping)` — is single-path in fact on every surface. Three breaking (pre-1.0) changes, all accepted.
28
+
29
+ ### Changed
30
+
31
+ - **Views self-shape on every surface.** The CLI runner's `result.to_h_for_wire` pre-wire is deleted, so every `view` receives the raw use-case result on every surface (the pattern `get`/`zone_mv`/`migrate` already used). `propose` collapses its two views into one `view { |env, _i| env.to_h_for_wire }`.
32
+ - **One normalizer home.** MCP's inline by-wire-name normalizer is lifted into `Contract::Binder.inputs_from_wire`, beside `inputs_from_ordered`; the binder now owns both. `MCP::Catalog#call` calls it.
33
+ - **Validation is unconditional; `required:` is an honest invariant.** The `validate:` keyword is dropped from `Binder.bind`, `RoleScope#dispatch_bound`, and `Maintenance::Migrate`. Bind always validates — no opt-out, so the `validate: false`-by-default footgun cannot exist. `required:` now means "required on every surface," not an agent-wire policy (retiring the [ADR 0066](docs/architecture/decisions/0066-one-binder-required-is-a-surface-policy.md) `validate:` fork).
34
+ - **The hand-authored CLI taxonomy is named and guarded.** `HAND_AUTHORED_VERBS` splits into `BEHAVIORAL_HATCHES` (`get`, `put`, `build` — genuine `< Runner::Base` overrides) and `NON_PROJECTED_CLI` (`fetch`, `fetch_all`, `boot`, `doctor` — plain `< Verb` commands), with the union derived. A new guard spec asserts each member's CLI class matches its category.
35
+
36
+ ### Breaking (pre-1.0)
37
+
38
+ - **`Binder.bind` / `RoleScope#dispatch_bound` drop the `validate:` keyword.** Callers passing it must remove it; validation is always on.
39
+ - **`put`/`propose` `meta` is now `required: false`.** A missing `_meta` no longer returns a pre-dispatch `missing _meta` error — it binds with `meta` absent and flows to schema validation downstream (where an *invalid* `_meta` fails, and an absent-but-schema-permitted `_meta` succeeds). `meta`'s real requiredness lives in schema validation.
40
+ - **MCP/Ruby `propose` returns the full wire envelope.** The single self-shaping view emits `env.to_h_for_wire` on every surface — a superset of the old `{uid, etag, key}`.
41
+
42
+ ## 0.45.0 — 2026-06-03 — The contract owns the request lifecycle ([ADRs 0066–0068](docs/architecture/decisions/))
43
+
44
+ No `textus/3` wire-format change. The `Contract` now owns the entire request lifecycle — `acquire → bind → invoke → render` — so the three surfaces (MCP, CLI, Ruby) project from it with no re-implemented argument-mapping, no dual response/cli_response shaping, and no behavioral escape-hatch classes that exist only because the contract couldn't express I/O acquisition, a stateful wrapper, or a coercion. The escape-hatch population shrinks from 18 to the irreducible behavioral floor of 7. Several breaking (pre-1.0) DSL and signature changes; one operator-visible CLI change (`key delete-prefix` / `key mv-prefix`).
45
+
46
+ ### Changed
47
+
48
+ - **One argument binder; `required:` is a surface policy ([ADR 0066](docs/architecture/decisions/0066-one-binder-required-is-a-surface-policy.md)).** `MCP::Catalog.map_args`, `CLI::Runner.call_args`, and `RoleScope`'s default-injection loop collapse into one `Contract::Binder.bind` over a uniform by-name `inputs` hash. Every surface dispatches through one site, `RoleScope#dispatch_bound`, so bind fires exactly once. The agent surfaces validate required args (`validate: true`); the Ruby API binds leniently and trusts the use-case's own keyword defaults — `required:` is an agent-wire policy, not a contract invariant.
49
+ - **BREAKING (pre-1.0): per-surface `view`s replace `response`/`cli_response` ([ADR 0067](docs/architecture/decisions/0067-per-surface-views.md)).** One `views` map keyed by surface: `view { … }` (MCP/Ruby) and `view(:cli) { … }`. Every view is called uniformly as `(result, inputs)`, retiring the `Proc#arity == 2` sniff. `Contract::View.render` is the single shaping entry point.
50
+ - **Declarative facets dissolve the acquisition & wrapper hatches ([ADR 0068](docs/architecture/decisions/0068-declarative-facets-dissolve-escape-hatches.md)).** `arg … source: :file` (read a path → contents), `arg … coerce: callable`, `cli_stdin :json` (stdin envelope), `around :name` (stateful wrappers via `Contract::Around`), and `arg … cli_default:` (a CLI default diverging from the agent default, driving both the value and boolean flag polarity). `propose`, `migrate`, `rule_lint`, `audit`, `zone_mv`, `pulse`, `key_delete`, and `mv` lose their hand-authored CLI classes and generate. `HAND_AUTHORED_VERBS` shrinks 18 → 7 (`get`, `put`, `build`, `fetch`, `fetch_all`, `boot`, `doctor`) — the behavioral floor. `zone_mv`/`migrate`/`key_*_prefix` apply by default on the CLI and plan by default for agents (ADR 0060), now legible in the contract rather than hidden in a hand class.
51
+ - **BREAKING (pre-1.0): `key delete --prefix P` → `key delete-prefix P`; `key mv --prefix F T` → `key mv-prefix F T`.** The two `--prefix` overloads split into first-class generated commands, each dispatching the verb its own contract names.
52
+ - **BREAKING (pre-1.0): positional `#call` signatures.** `zone_mv` (`from`/`to`), `migrate` (`plan_yaml`), `key_mv_prefix` (`from_prefix`/`to_prefix`), and `key_delete_prefix` (`prefix`) take their declared-positional args positionally, matching the CLI and the binder.
53
+
54
+ ### Fixed
55
+
56
+ - **`Runner.coerce` now coerces `Integer`-typed flags.** A `case … when Integer` used `===` (instance-of), so `Integer`-typed flags (`audit --limit`/`--seq-since`) silently stayed strings; now compared by equality.
57
+ - **The CLI reconciliation guards are seed-independent.** `CLI.verbs` and the contract-reconciliation check ignore anonymous (`name.nil?`) test-fixture `Verb` subclasses that previously leaked into the registry under some RSpec seeds.
58
+
59
+ ## 0.44.1 — 2026-06-03 — Finish the CLI contract projection ([ADR 0065](docs/architecture/decisions/0065-finish-cli-response-shrink-escape-hatches.md))
60
+
61
+ No `textus/3` wire-format change. The `cli_response` facet can now receive the call's resolved inputs, so the two output-only escape hatches become generated verbs. No operator-visible change to CLI commands, flags, or output.
62
+
63
+ ### Changed
64
+
65
+ - **`cli_response` may see the call inputs; `uid` and `blame` are now generated ([ADR 0065](docs/architecture/decisions/0065-finish-cli-response-shrink-escape-hatches.md)).** An arity-2 `cli_response` lambda receives `(result, inputs)` (inputs keyed by arg name), letting an envelope echo an input such as the key. `Read::Uid` and `Read::Blame` declare their CLI envelopes in their contracts and drop their hand-authored `Runner::Base` subclasses — the escape-hatch population shrinks from 13 to 11. `zone_mv` (CLI applies by default, agents plan by default — ADR 0060) and `audit` (a one-off `since` coercion) deliberately stay hand-authored.
66
+ - **BREAKING (pre-1.0): `Read::Blame#call` takes the key positionally.** `store.as(role).blame(key:)` becomes `store.as(role).blame(key)` to match the CLI's positional `blame KEY` and the generated arg-mapping. The CLI command and the MCP surface (blame is not MCP-surfaced) are unchanged.
67
+
68
+ ## 0.44.0 — 2026-06-03 — One verb name across surfaces; the CLI is a contract projection ([ADRs 0058–0064](docs/architecture/decisions/))
69
+
70
+ No `textus/3` wire-format change. This release makes a verb's *name* singular across the MCP tool, the CLI command, and the use-case method; widens and hardens the agent surface; unifies `get` on read-through; and makes the CLI a derived projection of the per-verb contract so a command can no longer dispatch a differently-named verb. Several breaking (pre-1.0) renames on the MCP/CLI surfaces; no operator-visible change to CLI commands, flags, or output.
71
+
72
+ ### Changed
73
+
74
+ - **BREAKING (pre-1.0): one verb name across surfaces ([ADR 0058](docs/architecture/decisions/0058-one-verb-name-across-surfaces.md)).** MCP `schema`→`schema_show`; CLI `fetch stale`→`fetch all` (the label now matches what it does); `retention_sweep`→`retain`; the redundant top-level `delete` folds into `key delete` (which gains `--if-etag`).
75
+ - **One rule verb, two depths ([ADR 0059](docs/architecture/decisions/0059-one-rule-verb-two-depths.md)).** MCP `rules` and CLI `rule explain` merge into `rule_explain` — lean `{fetch, guard}` by default, the full matched-blocks explanation under `--detail`/`detail: true`. `rule list` gains a use-case (`Read::RuleList`). Fixes a latent `to_h` crash on any key with a fetch rule.
76
+ - **Agent safety: eyes + safe defaults ([ADR 0060](docs/architecture/decisions/0060-agent-safety-graph-reads-and-default-dry-run.md)).** `deps`/`rdeps`/`where` are surfaced to MCP (the catalog grows 15→20 tools, including single-key `delete`/`mv`); the four bulk-destructive verbs (`zone_mv`, `key_mv_prefix`, `key_delete_prefix`, `migrate`) now default `dry_run: true` — an agent that omits the flag gets a Plan, not a mutation. `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` on every surface.
77
+ - **BREAKING (pre-1.0): `build` is the verb, `publish` is the output-destination noun ([ADR 0061](docs/architecture/decisions/0061-build-publish-vocabulary.md)).** `Write::Publish`→`Write::Build`; the dispatcher verb, `RoleScope#build`, the capability, the `derived` zone-kind's required verb, the lock, and the error all read `build`. `publish` is kept only for the ADR-0052 output-destination concept (the `publish:` block, `publish_to`/`publish_tree`, `Ports::Publisher`). Ruby callers using `store.as(role).publish` must call `.build`; the MCP surface is unaffected (the verb was never surfaced).
78
+ - **BREAKING (pre-1.0): one `get`, read-through everywhere ([ADR 0062](docs/architecture/decisions/0062-one-get-read-through.md)).** The public `get` is read-through on every surface (fetch-on-stale per the entry's rule, degrading to a pure read when there is no fetch rule), via a single `Read::Get#call(key, fetch:)` class; the CLI's secret `get_or_fetch` upgrade and the verb itself are removed. `get --no-fetch` (MCP `{fetch: false}`) forces a pure on-disk read. The agent's `get` now returns the freshest obtainable envelope (superseding ADR 0060's pure-read stance for `get`).
79
+ - **The CLI is a projection of the contract ([ADR 0063](docs/architecture/decisions/0063-cli-is-a-projection-of-the-contract.md)).** A generic `CLI::Runner` (the operator mirror of `MCP::Catalog`) generates each command from its contract via two new facets — `cli` (the command path) and `cli_response` (a CLI-specific return shape, where the operator envelope differs from the agent return). Twelve verbs are now generated with no hand-class; behavioral verbs subclass `Runner::Base` and override `#invoke` only, so the verb *name* is always contract-derived. A total reconciliation spec makes name/dispatch drift — the bug behind the 0058/0059/0061 renames — unrepresentable. No operator-visible change: the set of CLI commands, flags, and output is identical.
80
+ - **Derive the CLI command name; guard the dispatcher key ([ADR 0064](docs/architecture/decisions/0064-derive-command-name-and-guard-dispatcher-key.md)).** Tightens ADR 0063's projection: `CLI::Runner::Base` now derives `command_name` from the contract's `cli_leaf`, so the 13 escape-hatch classes drop their literal `command_name "…"` (the verb name is authored once, in the use-case). A new guard asserts every `Dispatcher::VERBS` key equals its use-case's own `contract.verb` — the last identity link no spec checked. No operator-visible change; the set of CLI commands, flags, and output is identical.
81
+
12
82
  ## 0.43.2 — 2026-06-02 — Agent-legible MCP contracts ([ADR 0057](docs/architecture/decisions/0057-agent-legible-mcp-contracts.md))
13
83
 
14
84
  No `textus/3` wire-format change. One breaking (pre-1.0) change on the MCP transport — `put`/`propose` take frontmatter under `_meta` (was `meta`) — plus per-argument descriptions on every MCP tool and a fully catalog-derived agent verb surface.
data/README.md CHANGED
@@ -72,6 +72,8 @@ TRANSIENT │ feeds │ proposals (queue) │
72
72
  raw material ──── propose ────► a human accept lifts it to canon
73
73
  ```
74
74
 
75
+ *(The fifth lane, `artifacts`, isn't on this grid — it's a derived **output**, computed from the lanes rather than an input climbing toward trust.)*
76
+
75
77
  Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** — called a **zone** in the manifest and CLI, the term used everywhere technical from here on — routes everything they can't write directly through a **proposals queue**, and writes every successful change to an **append-only audit log**. The lanes are enforced at the protocol level, not by convention.
76
78
 
77
79
  ```
@@ -91,9 +93,16 @@ That's the load-bearing claim: **coordination is a protocol invariant, not a lib
91
93
  ```sh
92
94
  gem install textus
93
95
  textus init # creates .textus/ with zones + schemas
94
- # agent proposes a change to proposals/
95
- printf '%s' '{"_meta":{"name":"oncall","proposal":{"target_key":"knowledge.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
96
- | textus put proposals.notes.oncall --as=agent --stdin
96
+
97
+ # an agent proposes a change — it targets a knowledge entry, but lands in proposals/
98
+ textus propose notes.oncall --as=agent --stdin <<'JSON'
99
+ {
100
+ "_meta": { "name": "oncall",
101
+ "proposal": { "target_key": "knowledge.notes.oncall", "action": "put" } },
102
+ "body": "Patrick on call.\n"
103
+ }
104
+ JSON
105
+
97
106
  # you accept it — textus promotes to knowledge/ and audits the move
98
107
  textus accept proposals.notes.oncall --as=human
99
108
  ```
@@ -130,7 +139,7 @@ bundle exec exe/textus --help
130
139
 
131
140
  ## What `textus init` gives you
132
141
 
133
- You get `.textus/` with all five zone directories, baseline schemas, an empty audit log, and a starter manifest. Roles declare capabilities; each zone declares a `kind:`, and write authority is derived from the role's capabilities crossed with the zone's kind:
142
+ You get `.textus/` with all five zone directories, baseline schemas, a starter manifest, and a gitignored `.run/` for disposable runtime state (the audit log, per-role cursors, fetch/build locks). Roles declare capabilities; each zone declares a `kind:`, and write authority is derived from the role's capabilities crossed with the zone's kind:
134
143
 
135
144
  ```yaml
136
145
  roles:
@@ -148,18 +157,22 @@ zones:
148
157
 
149
158
  ```
150
159
  .textus/
151
- manifest.yaml # role capabilities + zone kinds + key-to-path mapping
152
- audit.log # append-only NDJSON, every write
153
- schemas/ # YAML field shapes per entry family
154
- templates/ # mustache templates for derived entries
155
- hooks/ # one .rb per hook
156
- sentinels/ # publish bookkeeping
157
- zones/
158
- knowledge/ # author — identity (knowledge.identity.*), voice, decisions, notes
159
- notebook/ # keep — agent's own durable lane (agents keep theirs)
160
- feeds/ # fetch — declared external inputs (actions)
161
- proposals/ # propose (agent + human) — proposals awaiting accept
162
- artifacts/ # buildcomputed outputs
160
+ manifest.yaml # role capabilities + zone kinds + key-to-path mapping
161
+ schemas/ # YAML field shapes per entry family
162
+ templates/ # mustache templates for derived entries
163
+ hooks/ # one .rb per hook
164
+ .gitignore # generated — ignores .run/ and any tracked:false entries
165
+ zones/ # one dir per zone; kinds + capabilities are in the manifest above
166
+ knowledge/ # e.g. identity (knowledge.identity.*), voice, decisions, notes
167
+ notebook/
168
+ feeds/
169
+ proposals/
170
+ artifacts/
171
+ .run/ # disposable runtime state gitignored, safe to delete (ADR 0038)
172
+ audit/audit.log # append-only NDJSON event ledger, every write (rotates at ~50 MB)
173
+ state/cursor.<role> # per-role pulse cursor — where `pulse --since` resumes
174
+ locks/ build.lock # per-key fetch locks + the build mutex
175
+ sentinels/ # publish bookkeeping (target sha) — regenerated on build (ADR 0070)
163
176
  ```
164
177
 
165
178
  Manifest `path:` fields are relative to `.textus/zones/`. So `knowledge.notes.org.jane` lives at `.textus/zones/knowledge/notes/org/jane.md`.
@@ -176,7 +189,7 @@ textus rule list # show every rule block
176
189
  textus audit --limit=20 # query the audit log
177
190
  ```
178
191
 
179
- (All verbs return JSON envelopes by default; pass `--output=json` explicitly if you prefer.)
192
+ (All verbs return JSON envelopes; `--output=json` is the default and the only format in v1.)
180
193
 
181
194
  For a worked store — knowledge entries, a staged proposal, schemas, a template, and a build that publishes `CLAUDE.md` / `AGENTS.md` — see [`examples/project/`](examples/project/).
182
195
 
@@ -190,7 +203,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
190
203
 
191
204
  ## CLI and zones
192
205
 
193
- All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md). Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block).
206
+ Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` ([SPEC §3.1](SPEC.md)). Write verbs require `--as=<role>`, resolved as: `--as` flag **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` ([SPEC §5.1](SPEC.md)). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md).
194
207
 
195
208
  - Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
196
209
  - Zone semantics and the capability × zone-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/zones.md`](docs/reference/zones.md).
@@ -203,17 +216,31 @@ Derived entries declare `compute: { kind: projection, select: ..., pluck: ..., s
203
216
 
204
217
  For externally-generated entries, declare `compute: { kind: external, sources: [...] }` — textus tracks the declared sources for staleness; the build automation produces the file.
205
218
 
206
- Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single derived file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/sentinels/`. See SPEC §5.2, §5.3, §5.12.
219
+ Publishing is one typed `publish:` block (ADR 0052). `publish: { to: [path, ...] }` byte-copies a single derived file to one or more targets. `publish: { tree: "dir" }` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/.run/sentinels/` (git-ignored runtime state, regenerated on build — ADR 0070). See SPEC §5.2, §5.3, §5.12.
207
220
 
208
221
  ## Extension points
209
222
 
210
- textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path). Events:
223
+ textus exposes a hook DSL. Drop `.rb` files into `.textus/hooks/` (subdirectories are fine; files load alphabetically by full path). There are two kinds:
224
+
225
+ **RPC hooks** — one handler, the framework uses what you return:
226
+
227
+ | Event | Fires when | You return |
228
+ |---|---|---|
229
+ | `:resolve_intake` | a fetch needs bytes | `{_meta:, body:}` |
230
+ | `:transform_rows` | a projection builds | the reshaped rows |
231
+ | `:validate` | `textus doctor` runs | doctor issues (or none) |
232
+
233
+ **Pub-sub hooks** — 0..N handlers, fire-and-react (no return value):
211
234
 
212
- - `:resolve_intake` bring bytes in from elsewhere (returns `{_meta:, body:}`)
213
- - `:transform_rows` — transform rows during projection (returns rows)
214
- - `:validate` custom doctor check (returns issues)
215
- - `:entry_put`, `:entry_deleted`, `:entry_fetched`, `:build_completed`, `:proposal_accepted`, `:file_published`, `:entry_renamed`, `:proposal_rejected`, `:store_loaded` — react to lifecycle events
216
- - `:fetch_started`, `:fetch_failed`, `:fetch_backgrounded` background-fetch lifecycle
235
+ | Event(s) | Fires when |
236
+ |---|---|
237
+ | `:entry_put` · `:entry_deleted` · `:entry_renamed` | a write lands |
238
+ | `:entry_fetched` | a fetch-driven write lands |
239
+ | `:build_completed` | a derived entry materializes |
240
+ | `:file_published` | a derived file is copied to its target |
241
+ | `:proposal_accepted` · `:proposal_rejected` | a proposal is resolved |
242
+ | `:fetch_started` · `:fetch_failed` · `:fetch_backgrounded` | background-fetch lifecycle |
243
+ | `:store_loaded` | the store finishes loading |
217
244
 
218
245
  ```ruby
219
246
  # Inside .textus/hooks/local_file.rb
@@ -239,9 +266,9 @@ end
239
266
  To keep a batch of stale intake entries current in one shot:
240
267
 
241
268
  ```sh
242
- textus fetch stale --prefix=feeds --zone=feeds --as=automation
243
- # or just fetch everything stale in the feeds zone:
244
- textus fetch stale --zone=feeds --as=automation
269
+ textus fetch all --prefix=feeds --zone=feeds --as=automation
270
+ # or just fetch everything past its TTL in the feeds zone:
271
+ textus fetch all --zone=feeds --as=automation
245
272
  ```
246
273
 
247
274
  See SPEC.md §5.10 for the full hook contract.
@@ -273,4 +300,4 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
273
300
 
274
301
  ## License
275
302
 
276
- [MIT](LICENSE).
303
+ [MIT](LICENSE)
data/SPEC.md CHANGED
@@ -100,7 +100,7 @@ textus is organized as five composable layers. Each layer has a single responsib
100
100
  | L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
101
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`. |
102
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. |
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"`. |
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/.run/sentinels/<target-rel-path>.textus-managed.json` (git-ignored runtime state) records the source, sha256, and `mode: "copy"`. |
104
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. |
105
105
 
106
106
  ## 2. Goals and non-goals
@@ -135,7 +135,7 @@ The root is `.textus/` at the project working directory. A typical tree:
135
135
  schemas/ # internal: YAML schema files
136
136
  templates/ # internal: Mustache templates referenced by derived entries
137
137
  hooks/ # internal: one Ruby file per hook
138
- sentinels/ # internal: bookkeeping for byte-copied publish targets (see §5.3)
138
+ .run/sentinels/ # runtime (git-ignored): byte-copied publish bookkeeping, regenerated on build (see §5.3)
139
139
  zones/ # ALL user content lives here
140
140
  knowledge/ # zone: knowledge (kind: canon — author-holders write; knowledge.identity.* is the identity convention)
141
141
  notebook/ # zone: notebook (kind: workspace — keep-holders write; agent's own durable lane)
@@ -144,7 +144,7 @@ The root is `.textus/` at the project working directory. A typical tree:
144
144
  artifacts/ # zone: artifacts (kind: derived — build-holders write)
145
145
  ```
146
146
 
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.
147
+ Textus internals (`manifest.yaml`, `schemas/`, `templates/`, `hooks/`) live directly under `.textus/`; disposable runtime state (the audit log, publish `sentinels/`, fetch/build locks, pulse cursors) lives under `.textus/.run/` (git-ignored, ADR 0038/0070). **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.
148
148
 
149
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.
150
150
 
@@ -450,13 +450,13 @@ publish:
450
450
 
451
451
  When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
452
452
 
453
- A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. 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.
453
+ A sentinel is written for each published file at `<store_root>/.run/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 build (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.
454
454
 
455
- **Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `to:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), 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>/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 build envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused. When a `publish.tree` target overlaps a `derived` entry's `publish.to` (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).
455
+ **Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `to:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), 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>/.run/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 build envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused. When a `publish.tree` target overlaps a `derived` entry's `publish.to` (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).
456
456
 
457
457
  ### 5.4 Intake (declared, fetched via registered intake handler)
458
458
 
459
- 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:
459
+ 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 all`). The declaration is data only:
460
460
 
461
461
  ```yaml
462
462
  - key: feeds.calendar.events
@@ -478,7 +478,7 @@ rules:
478
478
 
479
479
  #### `on_stale:` semantics
480
480
 
481
- `on_stale:` declares what happens when `textus get` (or any read path that annotates freshness) encounters a stale intake entry. The value lives on the matching policy block, not on the entry. Vocabulary: `warn | sync | timed_sync`.
481
+ `on_stale:` declares what happens when `get` encounters a stale intake entry. `get` is **read-through on every surface** (CLI, Ruby, MCP): it returns the freshest obtainable envelope, fetching on a stale verdict per the entry's fetch rule and degrading to a pure on-disk read for keys with no fetch rule (ADR 0062). The value lives on the matching policy block, not on the entry. Vocabulary: `warn | sync | timed_sync`.
482
482
 
483
483
  | Value | Behaviour |
484
484
  |---|---|
@@ -499,7 +499,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
499
499
  **Fetch paths.** Two are supported:
500
500
 
501
501
  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).
502
- 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.
502
+ 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 all [--prefix=K] [--zone=Z]` drives this loop in one shot.
503
503
 
504
504
  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.
505
505
 
@@ -719,7 +719,7 @@ than aborting the run.
719
719
 
720
720
  **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`).
721
721
 
722
- **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.
722
+ **Read surface.** `textus rule list` dumps every block. `textus rule explain KEY` shows the resolved `RuleSet` for one key lean effective `{fetch, guard}` by default; `--detail` adds every matched block and the effective guard predicate names for every write transition (ADR 0059).
723
723
 
724
724
  ### 5.12 Storage formats
725
725
 
@@ -735,10 +735,10 @@ An entry's `format:` selects a storage strategy. All strategies expose the same
735
735
  **`_meta` convention.** Derived structured entries (json, yaml) embed a `_meta` hash as the first top-level key. Builder-injected keys appear in a fixed order for etag stability:
736
736
 
737
737
  ```
738
- generated_at, from, template, transform
738
+ from, template, transform
739
739
  ```
740
740
 
741
- Keys with `nil` values are omitted. User-shaped content (or the reducer's hash) follows `_meta`. The etag (§10) is the sha256 of the on-disk bytes regardless of format; key ordering MUST therefore be deterministic, which Ruby's `Hash` and `JSON.generate` / `YAML.dump` honor via insertion order.
741
+ Keys with `nil` values are omitted. The builder injects only **deterministic** provenance: it does **not** stamp a `generated_at` build timestamp into the artifact (ADR 0070). A built artifact is content-addressed — rebuilding unchanged sources reproduces it byte-for-byte, so a rebuild is a no-op and a `git` revert never drifts. (The `generated.at` of §5.2 is a separate convention written by *external* build tools, not by textus's own builder.) User-shaped content (or the reducer's hash) follows `_meta`. The etag (§10) is the sha256 of the on-disk bytes regardless of format; key ordering MUST therefore be deterministic, which Ruby's `Hash` and `JSON.generate` / `YAML.dump` honor via insertion order.
742
742
 
743
743
  ## 6. Schemas
744
744
 
@@ -866,7 +866,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
866
866
  |---|---|---|
867
867
  | `list [--prefix=K] [--zone=Z]` | read | any |
868
868
  | `where K` | read | any |
869
- | `get K` | read | any |
869
+ | `get K [--no-fetch]` | read (read-through by default: fetch-on-stale per the entry's fetch rule, degrades to a pure read; `--no-fetch` / `{fetch:false}` for an explicit pure on-disk read) | any |
870
870
  | `schema show K` | read | any |
871
871
  | `freshness [--prefix=K] [--zone=Z]` | read | any |
872
872
  | `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
@@ -881,9 +881,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
881
881
  | `pulse [--since=N]` | read | any |
882
882
  | `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
883
883
  | `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
884
- | `delete K --if-etag=E --as=R` | write | per zone |
884
+ | `key delete K --if-etag=E --as=R` | write | per zone |
885
885
  | `fetch KEY --as=automation` | write | `fetch`-holder (typically `automation`) |
886
- | `fetch stale [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
886
+ | `fetch all [--prefix=K] [--zone=Z] [--as=automation]` | write | `fetch`-holder (typically `automation`) |
887
887
  | `build [--prefix=K] [--dry-run]` | write | `build`-holder (typically `automation`) |
888
888
  | `retain [--prefix=K] [--zone=Z] --as=ROLE` | write | per zone (role must write the matched zone) |
889
889
  | `accept K --as=human` | write | `author`-holder (typically `human`) |
@@ -898,8 +898,8 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
898
898
  ```json
899
899
  {
900
900
  "agent_quickstart": {
901
- "read_verbs": ["get", "list", "pulse", "schema", "boot", "rules"],
902
- "write_verbs": ["put KEY --as=agent --stdin"],
901
+ "read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
902
+ "write_verbs": ["accept", "delete", "fetch", "fetch_all", "mv", "propose", "put", "reject"],
903
903
  "writable_zones": ["proposals"],
904
904
  "propose_zone": "proposals",
905
905
  "latest_seq": 1842
@@ -907,7 +907,9 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
907
907
  }
908
908
  ```
909
909
 
910
- `read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema` for an entry's field shape, `rules` for its freshness/guard policy) and never the CLI-only `audit`/`freshness`/`doctor` (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema` verb before a `put`/`propose`, not by shelling out to a CLI.
910
+ `read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its freshness/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`freshness`/`doctor` (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
911
+
912
+ The agent's MCP write surface includes the single-key `delete` and `mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 — verbs are actions, dry-run is opt-in on every surface). Single-key `delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport.
911
913
 
912
914
  `latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
913
915
 
@@ -936,7 +938,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
936
938
  "if_etag": "sha256:8f3c…" }
937
939
  ```
938
940
 
939
- `if_etag` is optional on `put`, required on `delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted on `put`, the write is unconditional (last-writer-wins).
941
+ `if_etag` is optional on both `put` and `delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted, the write is unconditional (last-writer-wins).
940
942
 
941
943
  **`textus freshness` output shape:**
942
944
 
@@ -1008,13 +1010,13 @@ Given the `person` schema and a `put` whose frontmatter omits `relationship`, th
1008
1010
  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.
1009
1011
 
1010
1012
  **Fixture E — Projection build:**
1011
- 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.
1013
+ 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. The output is content-addressed (no `generated_at` timestamp, ADR 0070), so rebuilding with unchanged sources reproduces it byte-for-byte and writes nothing.
1012
1014
 
1013
1015
  **Fixture F — Mustache render:**
1014
1016
  Given a derived entry with a `template` clause referencing a `.mustache` file and inputs drawn from other keys, `textus build` produces a body whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization).
1015
1017
 
1016
1018
  **Fixture G — Copy publish:**
1017
- Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
1019
+ Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/.run/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
1018
1020
 
1019
1021
  **Fixture H — Audit log format:**
1020
1022
  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.
@@ -1109,7 +1111,7 @@ textus does not ship a built-in textus/2 → textus/3 migrator. The historical u
1109
1111
  | Compute field | `projection.reduce:` | `compute.transform:` |
1110
1112
  | `_meta` key | `reducer` | `transform` |
1111
1113
  | CLI flag | `--format=json` (envelope) | `--output=json` |
1112
- | CLI verb | `refresh-stale` | `fetch stale` |
1114
+ | CLI verb | `refresh-stale` | `fetch all` |
1113
1115
  | CLI verb | `policy list/explain` | `rule list/explain` |
1114
1116
 
1115
1117
  **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,7 +1126,7 @@ textus does not ship a built-in textus/2 → textus/3 migrator. The historical u
1124
1126
  | `roles[*].kind:` (`accept_authority`/`generator`/`proposer`/`runner`) | `roles[*].can:` (subset of `propose`, `author`, `fetch`, `build`) |
1125
1127
  | Actors `runner`, `builder` | `automation` (`can: [fetch, build]`) by default |
1126
1128
  | `rules[*].refresh:` slot | `rules[*].fetch:` slot |
1127
- | CLI `textus refresh` / `refresh stale` | `textus fetch` / `fetch stale` |
1129
+ | CLI `textus refresh` / `refresh stale` | `textus fetch` / `fetch all` |
1128
1130
  | `_meta.last_refreshed_at` | `_meta.last_fetched_at` |
1129
1131
  | Promotion predicate `:human_accept` / `:accept_authority_signed` | `:author_signed` |
1130
1132
  | Envelope `refreshing` | `fetching` |
@@ -28,6 +28,23 @@ CLI verbs: store.<verb>(..., role:)
28
28
  MCP gate: textus mcp serve — same use cases, JSON-RPC.
29
29
  ```
30
30
 
31
+ The CLI is a **projection of the per-verb `Contract`** (ADR 0063), the operator
32
+ mirror of `MCP::Catalog`. The contract now owns the whole request lifecycle —
33
+ `acquire → bind → invoke → render` (ADRs 0066–0068): one `Contract::Binder.bind`
34
+ splits the uniform by-name `inputs` hash into the use-case's positional/keyword
35
+ args for every surface; per-surface `view`s shape the output (`view` for
36
+ MCP/Ruby, `view(:cli)` for the operator envelope); declarative `source:`/
37
+ `coerce:`/`cli_stdin` populate inputs from files and stdin; `around:` resources
38
+ wrap the single dispatch site (`RoleScope#dispatch_bound`) for stateful verbs;
39
+ and `cli_default:` declares a CLI default that diverges from the agent default.
40
+ `CLI::Runner` generates a command per `:cli` contract, dispatching `contract.verb`
41
+ by construction. Only verbs with genuine *behavior* — `put` (fetch
42
+ orchestration), `get` (UnknownKey + resolver suggestions, CLI-only), `build`
43
+ (actor-role resolution + BuildLock), the `fetch`/`fetch_all` workers, and the
44
+ `boot`/`doctor` composite reports — stay hand-authored, plus commands with no
45
+ dispatcher verb (`init`, `hook`, `mcp serve`, `schema diff/init`). Total
46
+ reconciliation specs make name/dispatch/facet drift unrepresentable.
47
+
31
48
  **Application**
32
49
 
33
50
  ```
@@ -37,10 +54,10 @@ Container (single record — wired ports + manifest)
37
54
  Dispatcher (static VERBS table: verb → use-case)
38
55
  RoleScope (Store#as(role) — forwards verb calls)
39
56
 
40
- read/{get,get_or_fetch,list,where,uid,schema_envelope,
57
+ read/{get,list,where,uid,schema_envelope,
41
58
  deps,rdeps,published,stale,validate_all,boot,doctor,
42
- freshness,audit,blame,policy_explain,pulse}.rb
43
- write/{put,delete,mv,accept,reject,publish,
59
+ freshness,audit,blame,rule_explain,rule_list,pulse}.rb
60
+ write/{put,delete,mv,accept,reject,build,
44
61
  materializer,intake_fetch,retention_sweep,
45
62
  fetch_worker,fetch_orchestrator,fetch_all}
46
63
  maintenance/{migrate,key_mv_prefix,key_delete_prefix,
@@ -171,13 +188,15 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
171
188
 
172
189
  ## Read path (`store.get(key)`)
173
190
 
191
+ `Read::Get` is the single public read verb (ADR 0062). It is read-through by default: it returns the freshest obtainable envelope, fetching on a stale verdict per the entry's fetch rule, and degrading to a pure on-disk result when the key has no fetch rule. An optional `fetch: false` flag (CLI `--no-fetch`, MCP `{fetch:false}`) forces a pure on-disk read.
192
+
174
193
  1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
175
- 2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`.
176
- 3. `Read::Get#call` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope.
177
- 4. Looks up the fetch policy via `container.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
178
- 5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `fetching: false`.
194
+ 2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The contract declares `arg :fetch, default: true`, injected by `Contract::Binder.bind` at the single verb-dispatch chokepoint (`RoleScope#dispatch_bound`) for every surface — so the public verb is always read-through unless the caller explicitly passes `fetch: false`.
195
+ 3. `Read::Get#call(key, fetch: false)` runs the pure read sub-step inline: resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no fetch rule, the envelope is annotated fresh and returned immediately — no orchestrator is involved.
196
+ 4. If `fetch: true` and the verdict is stale and the entry's fetch rule demands action, `Read::Get` hands off to `Write::FetchOrchestrator` (built lazily — a pure `fetch: false` call never touches the orchestrator). The orchestrator executes the fetch policy's `Action` (`sync`, `timed_sync`, `detached`, …) and returns an `Outcome`.
197
+ 5. The outcome is mapped back to an envelope: `Fetched` → fresh envelope from the write; `Detached` original envelope with `fetching: true`; `Failed` original envelope with `fetch_error` set; `Skipped` → original envelope unchanged.
179
198
 
180
- `store.get_or_fetch(key)` composes `Read::Get` with `Write::FetchOrchestrator` to optionally fetch on stale.
199
+ The pure read is `Read::Get#call(key, fetch: false)` it is the safe default for direct in-process callers (accept/reject/publish, materializer, uid, validate_all/validator, schema/tools, hooks/context) that must never trigger a fetch. They construct `Read::Get` directly, bypassing the dispatch injection that sets `fetch: true`. The prior separate read-through path `get_or_fetch` and the separate pure class `Read::GetEntry` were both unified into the one `Read::Get` class (ADR 0062 amendment).
181
200
 
182
201
  ## Write path (`store.put(key, ...)`)
183
202
 
@@ -186,7 +205,7 @@ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Mani
186
205
  3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
187
206
  4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
188
207
 
189
- `Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
208
+ `Write::{Delete,Mv,Accept,Reject,Build}` follow the same shape: explicit container, the unified `Guard` for authz (built per transition via `GuardFactory`), `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
190
209
 
191
210
  `Write::Mv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
192
211
 
@@ -226,31 +245,12 @@ The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
226
245
 
227
246
  1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
228
247
  2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
229
- 3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema`, `rules`.
248
+ 3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
230
249
 
231
250
  Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
232
251
 
233
252
  ## Hooks event catalog
234
253
 
235
- `Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027).
236
-
237
- RPC (single handler, declares `caps:`):
238
- - `resolve_intake(caps:, config:, args:)` — intake fetch handler.
239
- - `transform_rows(caps:, rows:, config:)` — row transform for intakes.
240
- - `validate(caps:)` — custom doctor validator.
241
-
242
- Pub-sub (0..N handlers, declare `ctx:`):
243
- - `entry_put(ctx:, key:, envelope:)`
244
- - `entry_deleted(ctx:, key:)`
245
- - `entry_fetched(ctx:, key:, envelope:, change:)`
246
- - `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
247
- - `build_completed(ctx:, key:, envelope:, sources:)`
248
- - `proposal_accepted(ctx:, key:, target_key:)`
249
- - `proposal_rejected(ctx:, key:, target_key:)`
250
- - `file_published(ctx:, key:, envelope:, source:, target:)`
251
- - `store_loaded(ctx:)`
252
- - `fetch_started(ctx:, key:, mode:)`
253
- - `fetch_failed(ctx:, key:, error_class:, error_message:)`
254
- - `fetch_backgrounded(ctx:, key:, started_at:, budget_ms:)`
255
-
256
- Authoritative source: `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
254
+ `Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027). RPC handlers declare `caps:` (single handler); pub-sub handlers declare `ctx:` (0..N handlers).
255
+
256
+ The event names, payloads, and per-verb firing order are documented once in [`reference/events.md`](../reference/events.md) (the friendly SSoT); the authoritative source is `lib/textus/hooks/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`).
@@ -92,7 +92,7 @@ External inputs land via `:resolve_intake` hooks, not shell commands. Each intak
92
92
 
93
93
  ```sh
94
94
  textus fetch feeds.notion.roadmap --as=automation
95
- textus fetch stale --zone=feeds --as=automation # everything past its TTL
95
+ textus fetch all --zone=feeds --as=automation # everything past its TTL
96
96
  ```
97
97
 
98
98
  Freshness budgets live in the top-level `rules:` block, matched by glob:
@@ -103,24 +103,23 @@ rules:
103
103
  fetch: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
104
104
  ```
105
105
 
106
- A typical scheduled-fetch integration shells the `fetch stale` sweep itself:
106
+ A typical scheduled-fetch integration shells the `fetch all` sweep itself:
107
107
 
108
108
  ```sh
109
- textus fetch stale --zone=feeds --as=automation # in cron / CI
109
+ textus fetch all --zone=feeds --as=automation # in cron / CI
110
110
  ```
111
111
 
112
112
  See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
113
113
 
114
114
  ### Read vs. fetch
115
115
 
116
- There are two read operations, and the difference matters in custom code:
116
+ There is one public read operation (ADR 0062):
117
117
 
118
- | Operation | Triggers fetch? | Use for |
119
- |-----------|-----------------|---------|
120
- | `ops.get` | Nopure read of on-disk envelope + freshness verdict | build / materialization paths, schema tooling, any context where a side-effecting read would surprise the caller |
121
- | `ops.get_or_fetch` | Yes — composes `get` with the orchestrator per the rule's `on_stale` policy | interactive reads (`textus get`, dashboards) where the caller wants the freshest envelope obtainable |
118
+ | Operation | Behaviour | Use for |
119
+ |-----------|-----------|---------|
120
+ | `ops.get` | Read-through by default fetches on stale per the entry's fetch rule; degrades to a pure on-disk read when the key has no fetch rule. Pass `fetch: false` (CLI `--no-fetch`, MCP `{fetch:false}`) for an explicit pure read | all callers, including interactive reads, dashboards, and scripts that want the freshest obtainable envelope |
122
121
 
123
- Build always uses the pure path; injecting fetch into materialization caused the cascading-staleness incident behind issue #59. Pick `get_or_fetch` only when you genuinely want side effects on read.
122
+ Build pipelines and other internal callers that must never trigger a fetch (materializer, projection, schema tooling, accept/reject/publish, uid, validator) construct `Read::Get` directly with the method default `fetch: false` — a pure, orchestrator-free read. They bypass the verb-dispatch injection that sets `fetch: true`, so they always get pure reads without any extra argument.
124
123
 
125
124
  ## Body content
126
125