textus 0.55.2 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +175 -2768
- data/README.md +8 -9
- data/lib/textus/artifact.rb +8 -0
- data/lib/textus/errors.rb +0 -39
- data/lib/textus/human.rb +7 -0
- data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
- data/lib/textus/infra/clock.rb +7 -0
- data/lib/textus/infra/database.rb +127 -0
- data/lib/textus/infra/file_system.rb +54 -0
- data/lib/textus/infra/locks.rb +50 -0
- data/lib/textus/infra/port/clock.rb +18 -0
- data/lib/textus/infra/port/publisher.rb +71 -0
- data/lib/textus/infra/port/sentinel.rb +25 -0
- data/lib/textus/infra/port/sentinel_store.rb +93 -0
- data/lib/textus/infra/store/audit_log.rb +42 -0
- data/lib/textus/infra/store/base.rb +25 -0
- data/lib/textus/infra/store/cursor_store.rb +27 -0
- data/lib/textus/infra/store/entry_index.rb +31 -0
- data/lib/textus/infra/store/envelope/meta.rb +63 -0
- data/lib/textus/infra/store/file_store.rb +23 -0
- data/lib/textus/infra/store/index/builder.rb +28 -0
- data/lib/textus/infra/store/index/lookup.rb +23 -0
- data/lib/textus/infra/store/link_store.rb +43 -0
- data/lib/textus/infra/store/workflow_queue.rb +54 -0
- data/lib/textus/infra/store.rb +29 -0
- data/lib/textus/ingest.rb +8 -0
- data/lib/textus/lanes/artifact/cache.rb +35 -0
- data/lib/textus/lanes/artifact/handlers.rb +29 -0
- data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
- data/lib/textus/lanes/artifact/verbs.rb +18 -0
- data/lib/textus/lanes/ingest/dedup.rb +17 -0
- data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
- data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
- data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
- data/lib/textus/lanes/ingest/entry_types.rb +25 -0
- data/lib/textus/lanes/ingest/handlers.rb +54 -0
- data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
- data/lib/textus/lanes/ingest/key_builder.rb +33 -0
- data/lib/textus/lanes/ingest/resolver.rb +21 -0
- data/lib/textus/lanes/knowledge/freeform.rb +15 -0
- data/lib/textus/lanes/knowledge/handlers.rb +53 -0
- data/lib/textus/lanes/knowledge/runner.rb +45 -0
- data/lib/textus/lanes/knowledge/verbs.rb +16 -0
- data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
- data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
- data/lib/textus/{dispatch → protocol}/binder.rb +1 -7
- data/lib/textus/protocol/boot/catalog.rb +34 -0
- data/lib/textus/protocol/boot/context.rb +124 -0
- data/lib/textus/protocol/boot/protocol.rb +33 -0
- data/lib/textus/protocol/boot.rb +42 -0
- data/lib/textus/protocol/builder/domain.rb +17 -0
- data/lib/textus/protocol/builder/infrastructure.rb +17 -0
- data/lib/textus/protocol/builder/runtime.rb +46 -0
- data/lib/textus/protocol/builder.rb +11 -0
- data/lib/textus/protocol/container.rb +22 -0
- data/lib/textus/protocol/diff.rb +170 -0
- data/lib/textus/protocol/entry_constraint.rb +54 -0
- data/lib/textus/protocol/envelope.rb +70 -0
- data/lib/textus/protocol/format/base.rb +80 -0
- data/lib/textus/protocol/format/bash.rb +10 -0
- data/lib/textus/protocol/format/javascript.rb +10 -0
- data/lib/textus/protocol/format/json.rb +82 -0
- data/lib/textus/protocol/format/markdown.rb +53 -0
- data/lib/textus/protocol/format/python.rb +10 -0
- data/lib/textus/protocol/format/ruby.rb +10 -0
- data/lib/textus/protocol/format/script.rb +55 -0
- data/lib/textus/protocol/format/text.rb +48 -0
- data/lib/textus/protocol/format/yaml.rb +105 -0
- data/lib/textus/protocol/format.rb +73 -0
- data/lib/textus/{store → protocol}/freshness/ttl_evaluator.rb +7 -3
- data/lib/textus/protocol/gate.rb +61 -0
- data/lib/textus/protocol/handlers/command_context.rb +19 -0
- data/lib/textus/protocol/handlers/concern.rb +15 -0
- data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
- data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
- data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
- data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
- data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
- data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
- data/lib/textus/protocol/handlers/query_context.rb +41 -0
- data/lib/textus/protocol/handlers/read.rb +67 -0
- data/lib/textus/protocol/handlers/system.rb +93 -0
- data/lib/textus/protocol/handlers/write.rb +57 -0
- data/lib/textus/protocol/init.rb +143 -0
- data/lib/textus/protocol/key/grammar.rb +35 -0
- data/lib/textus/protocol/key/matching.rb +23 -0
- data/lib/textus/protocol/key/path.rb +23 -0
- data/lib/textus/protocol/key.rb +25 -0
- data/lib/textus/protocol/lane.rb +12 -0
- data/lib/textus/{store → protocol}/layout.rb +16 -22
- data/lib/textus/protocol/links/resolver.rb +33 -0
- data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
- data/lib/textus/protocol/links.rb +10 -0
- data/lib/textus/protocol/manifest/data.rb +90 -0
- data/lib/textus/protocol/manifest/entry/base.rb +95 -0
- data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
- data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
- data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
- data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
- data/lib/textus/protocol/manifest/entry.rb +10 -0
- data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
- data/lib/textus/protocol/manifest/policy.rb +69 -0
- data/lib/textus/protocol/manifest/resolver.rb +156 -0
- data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
- data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
- data/lib/textus/protocol/manifest/schema.rb +40 -0
- data/lib/textus/protocol/manifest.rb +62 -0
- data/lib/textus/protocol/produce/render/context.rb +43 -0
- data/lib/textus/protocol/produce/render.rb +44 -0
- data/lib/textus/protocol/schema/registry.rb +44 -0
- data/lib/textus/protocol/schema/tools.rb +104 -0
- data/lib/textus/protocol/schema.rb +117 -0
- data/lib/textus/protocol/session/bridge.rb +11 -0
- data/lib/textus/protocol/session/context.rb +5 -0
- data/lib/textus/protocol/session.rb +87 -0
- data/lib/textus/protocol/store_engine/delete.rb +37 -0
- data/lib/textus/protocol/store_engine/move.rb +109 -0
- data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
- data/lib/textus/protocol/store_engine/put.rb +104 -0
- data/lib/textus/protocol/store_engine.rb +86 -0
- data/lib/textus/protocol/v4.rb +25 -0
- data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
- data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
- data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
- data/lib/textus/protocol/verb_registry.rb +42 -0
- data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
- data/lib/textus/surface/cli/runner.rb +22 -21
- data/lib/textus/surface/cli/sources.rb +1 -3
- data/lib/textus/surface/cli/verb/get.rb +2 -7
- data/lib/textus/surface/cli/verb/init.rb +1 -1
- data/lib/textus/surface/cli/verb/put.rb +7 -7
- data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
- data/lib/textus/surface/cli.rb +25 -6
- data/lib/textus/surface/dispatch.rb +17 -0
- data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
- data/lib/textus/surface/mcp/catalog.rb +12 -12
- data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
- data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
- data/lib/textus/surface/mcp/server.rb +35 -24
- data/lib/textus/surface/watcher.rb +18 -12
- data/lib/textus/surface/web.rb +50 -0
- data/lib/textus/value/call.rb +1 -1
- data/lib/textus/value/etag.rb +7 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/consumer.rb +39 -0
- data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
- data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
- data/lib/textus/workflow/dsl/step_builder.rb +56 -0
- data/lib/textus/workflow/dsl.rb +111 -21
- data/lib/textus/workflow/event_emitter.rb +21 -0
- data/lib/textus/workflow/publisher.rb +59 -0
- data/lib/textus/workflow/registry.rb +31 -8
- data/lib/textus/workflow/runner.rb +25 -53
- data/lib/textus/workflow/scheduler.rb +45 -0
- data/lib/textus/workflow/step_executor.rb +78 -0
- data/lib/textus/workflow/step_helpers.rb +102 -0
- data/lib/textus/workflow.rb +2 -5
- data/lib/textus.rb +16 -16
- metadata +169 -185
- data/SPEC.md +0 -909
- data/docs/architecture/README.md +0 -273
- data/docs/reference/conventions.md +0 -141
- data/lib/textus/boot.rb +0 -181
- data/lib/textus/dispatch/contracts.rb +0 -63
- data/lib/textus/dispatch/handler_registry.rb +0 -21
- data/lib/textus/dispatch/middleware/audit_index.rb +0 -51
- data/lib/textus/dispatch/middleware/auth.rb +0 -40
- data/lib/textus/dispatch/middleware/base.rb +0 -26
- data/lib/textus/dispatch/middleware/binder.rb +0 -20
- data/lib/textus/dispatch/middleware/cascade.rb +0 -53
- data/lib/textus/dispatch/pipeline.rb +0 -35
- data/lib/textus/doctor/check/audit_log.rb +0 -34
- data/lib/textus/doctor/check/generator_drift.rb +0 -29
- data/lib/textus/doctor/check/illegal_keys.rb +0 -60
- data/lib/textus/doctor/check/manifest_files.rb +0 -25
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
- data/lib/textus/doctor/check/proposal_targets.rb +0 -45
- data/lib/textus/doctor/check/protocol_version.rb +0 -47
- data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
- data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
- data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
- data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
- data/lib/textus/doctor/check/schema_violations.rb +0 -28
- data/lib/textus/doctor/check/schemas.rb +0 -26
- data/lib/textus/doctor/check/scratchpad_sources.rb +0 -55
- data/lib/textus/doctor/check/sentinels.rb +0 -60
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
- data/lib/textus/doctor/check/templates.rb +0 -29
- data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
- data/lib/textus/doctor/check.rb +0 -43
- data/lib/textus/doctor/validator.rb +0 -93
- data/lib/textus/doctor.rb +0 -54
- data/lib/textus/format/base.rb +0 -77
- data/lib/textus/format/json.rb +0 -81
- data/lib/textus/format/markdown.rb +0 -59
- data/lib/textus/format/text.rb +0 -55
- data/lib/textus/format/yaml.rb +0 -104
- data/lib/textus/format.rb +0 -61
- data/lib/textus/handlers/maintenance/boot_store.rb +0 -15
- data/lib/textus/handlers/maintenance/doctor_store.rb +0 -15
- data/lib/textus/handlers/maintenance/drain_store.rb +0 -21
- data/lib/textus/handlers/maintenance/ingest_entry.rb +0 -159
- data/lib/textus/handlers/maintenance/jobs_action.rb +0 -21
- data/lib/textus/handlers/maintenance/published_entries.rb +0 -17
- data/lib/textus/handlers/maintenance/rule_explain.rb +0 -77
- data/lib/textus/handlers/maintenance/rule_lint.rb +0 -54
- data/lib/textus/handlers/maintenance/rule_list.rb +0 -32
- data/lib/textus/handlers/maintenance/schema_envelope.rb +0 -19
- data/lib/textus/handlers/read/audit_entries.rb +0 -48
- data/lib/textus/handlers/read/blame_entry.rb +0 -71
- data/lib/textus/handlers/read/deps_entry.rb +0 -17
- data/lib/textus/handlers/read/get_entry.rb +0 -68
- data/lib/textus/handlers/read/list_keys.rb +0 -36
- data/lib/textus/handlers/read/pulse_entries.rb +0 -66
- data/lib/textus/handlers/read/rdeps_entry.rb +0 -21
- data/lib/textus/handlers/read/uid_entry.rb +0 -18
- data/lib/textus/handlers/read/where_entry.rb +0 -18
- data/lib/textus/handlers/write/accept_proposal.rb +0 -39
- data/lib/textus/handlers/write/data_mv.rb +0 -55
- data/lib/textus/handlers/write/delete_key.rb +0 -17
- data/lib/textus/handlers/write/enqueue_job.rb +0 -27
- data/lib/textus/handlers/write/key_delete_prefix.rb +0 -32
- data/lib/textus/handlers/write/key_mv_prefix.rb +0 -45
- data/lib/textus/handlers/write/move_key.rb +0 -80
- data/lib/textus/handlers/write/propose_entry.rb +0 -29
- data/lib/textus/handlers/write/put_entry.rb +0 -29
- data/lib/textus/handlers/write/reject_proposal.rb +0 -29
- data/lib/textus/init.rb +0 -157
- data/lib/textus/jobs.rb +0 -9
- data/lib/textus/key/distance.rb +0 -55
- data/lib/textus/key/grammar.rb +0 -33
- data/lib/textus/key/matching.rb +0 -24
- data/lib/textus/key/path.rb +0 -28
- data/lib/textus/manifest/capabilities.rb +0 -29
- data/lib/textus/manifest/data.rb +0 -102
- data/lib/textus/manifest/entry/base.rb +0 -105
- data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
- data/lib/textus/manifest/entry/leaf.rb +0 -17
- data/lib/textus/manifest/entry/nested.rb +0 -37
- data/lib/textus/manifest/entry/parser.rb +0 -99
- data/lib/textus/manifest/entry/produced.rb +0 -36
- data/lib/textus/manifest/entry/publish/mode.rb +0 -45
- data/lib/textus/manifest/entry/publish/none.rb +0 -14
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
- data/lib/textus/manifest/entry/publish/template.rb +0 -16
- data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
- data/lib/textus/manifest/entry/publish/tree.rb +0 -50
- data/lib/textus/manifest/entry/publish.rb +0 -45
- data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
- data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
- data/lib/textus/manifest/entry/validators/publish.rb +0 -30
- data/lib/textus/manifest/entry/validators.rb +0 -18
- data/lib/textus/manifest/entry.rb +0 -8
- data/lib/textus/manifest/policy/matcher.rb +0 -51
- data/lib/textus/manifest/policy/predicates/author_held.rb +0 -22
- data/lib/textus/manifest/policy/predicates/etag_match.rb +0 -18
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +0 -13
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +0 -31
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +0 -23
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +0 -25
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +0 -24
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +0 -41
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +0 -20
- data/lib/textus/manifest/policy/predicates.rb +0 -54
- data/lib/textus/manifest/policy/publish_target.rb +0 -34
- data/lib/textus/manifest/policy/react.rb +0 -30
- data/lib/textus/manifest/policy/retention.rb +0 -29
- data/lib/textus/manifest/policy/source.rb +0 -30
- data/lib/textus/manifest/policy.rb +0 -95
- data/lib/textus/manifest/resolver.rb +0 -118
- data/lib/textus/manifest/rules.rb +0 -86
- data/lib/textus/manifest/schema/contract.rb +0 -61
- data/lib/textus/manifest/schema/keys.rb +0 -90
- data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
- data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
- data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
- data/lib/textus/manifest/schema/semantics.rb +0 -27
- data/lib/textus/manifest/schema/validator.rb +0 -48
- data/lib/textus/manifest/schema/vocabulary.rb +0 -25
- data/lib/textus/manifest/schema.rb +0 -30
- data/lib/textus/manifest.rb +0 -65
- data/lib/textus/orchestration.rb +0 -55
- data/lib/textus/port/audit_log.rb +0 -268
- data/lib/textus/port/build_lock.rb +0 -64
- data/lib/textus/port/clock.rb +0 -10
- data/lib/textus/port/publisher.rb +0 -60
- data/lib/textus/port/sentinel.rb +0 -25
- data/lib/textus/port/sentinel_store.rb +0 -83
- data/lib/textus/port/storage/file_stat.rb +0 -19
- data/lib/textus/port/storage/file_store.rb +0 -49
- data/lib/textus/port/storage/interface.rb +0 -17
- data/lib/textus/port/store.rb +0 -149
- data/lib/textus/port/watcher_lock.rb +0 -48
- data/lib/textus/produce/engine.rb +0 -40
- data/lib/textus/produce/publisher.rb +0 -21
- data/lib/textus/produce/render.rb +0 -20
- data/lib/textus/schema/registry.rb +0 -42
- data/lib/textus/schema/tools.rb +0 -108
- data/lib/textus/schema.rb +0 -100
- data/lib/textus/store/container.rb +0 -173
- data/lib/textus/store/cursor.rb +0 -26
- data/lib/textus/store/entry/reader.rb +0 -47
- data/lib/textus/store/entry/writer.rb +0 -219
- data/lib/textus/store/envelope/meta.rb +0 -61
- data/lib/textus/store/freshness/drift_detector.rb +0 -93
- data/lib/textus/store/freshness/evaluator.rb +0 -20
- data/lib/textus/store/freshness/verdict.rb +0 -24
- data/lib/textus/store/freshness.rb +0 -8
- data/lib/textus/store/index/builder.rb +0 -76
- data/lib/textus/store/index/lookup.rb +0 -60
- data/lib/textus/store/jobs/base.rb +0 -13
- data/lib/textus/store/jobs/index.rb +0 -15
- data/lib/textus/store/jobs/materialize.rb +0 -15
- data/lib/textus/store/jobs/plan.rb +0 -11
- data/lib/textus/store/jobs/planner.rb +0 -124
- data/lib/textus/store/jobs/queue.rb +0 -162
- data/lib/textus/store/jobs/registry.rb +0 -19
- data/lib/textus/store/jobs/retention/base.rb +0 -52
- data/lib/textus/store/jobs/retention/sweep.rb +0 -55
- data/lib/textus/store/jobs/retention.rb +0 -8
- data/lib/textus/store/jobs/sweep.rb +0 -21
- data/lib/textus/store/jobs/worker.rb +0 -64
- data/lib/textus/store.rb +0 -122
- data/lib/textus/surface/cli/verb/doctor.rb +0 -20
- data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
- data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
- data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
- data/lib/textus/surface/mcp/projector.rb +0 -20
- data/lib/textus/surface/mcp.rb +0 -8
- data/lib/textus/value/command.rb +0 -16
- data/lib/textus/value/envelope.rb +0 -90
- data/lib/textus/value/result.rb +0 -46
- data/lib/textus/verb_registry.rb +0 -417
- data/lib/textus/workflow/collector.rb +0 -27
- data/lib/textus/workflow/context.rb +0 -5
- data/lib/textus/workflow/loader.rb +0 -17
- data/lib/textus/workflow/pattern.rb +0 -18
data/SPEC.md
DELETED
|
@@ -1,909 +0,0 @@
|
|
|
1
|
-
# textus/4 — Specification
|
|
2
|
-
|
|
3
|
-
**Status:** Accepted v4.0
|
|
4
|
-
**Protocol identifier:** `textus/4`
|
|
5
|
-
**Reference implementation:** Ruby gem `textus`
|
|
6
|
-
|
|
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.
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Table of contents
|
|
12
|
-
|
|
13
|
-
- [Conventions](#conventions)
|
|
14
|
-
- [1. What textus is](#1-what-textus-is)
|
|
15
|
-
- [2. Goals and non-goals](#2-goals-and-non-goals)
|
|
16
|
-
- [3. Storage layout](#3-storage-layout)
|
|
17
|
-
- [4. Manifest](#4-manifest)
|
|
18
|
-
- [5. Lanes and capability-based write gates](#5-lanes-and-capability-based-write-gates)
|
|
19
|
-
- [6. Schemas](#6-schemas)
|
|
20
|
-
- [7. Entry file format](#7-entry-file-format)
|
|
21
|
-
- [8. Envelope (the wire format)](#8-envelope-the-wire-format)
|
|
22
|
-
- [9. CLI surface](#9-cli-surface)
|
|
23
|
-
- [10. ETag semantics](#10-etag-semantics)
|
|
24
|
-
- [11. Versioning](#11-versioning)
|
|
25
|
-
- [12. Conformance fixtures](#12-conformance-fixtures)
|
|
26
|
-
- [13. Why not X?](#13-why-not-x)
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## Conventions
|
|
31
|
-
|
|
32
|
-
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**,
|
|
33
|
-
**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this
|
|
34
|
-
document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
|
|
35
|
-
and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174): with their normative
|
|
36
|
-
meaning **only** when they appear in uppercase. The same words in lowercase
|
|
37
|
-
carry their ordinary English sense and impose no requirement.
|
|
38
|
-
|
|
39
|
-
Requirements are stated against any **conforming implementation** of the
|
|
40
|
-
`textus/4` protocol. The Ruby gem `textus` is the reference implementation, but
|
|
41
|
-
the contract is the protocol defined here — not the gem. Where this document and
|
|
42
|
-
the implementation disagree, this document is the source of truth and the
|
|
43
|
-
implementation is the bug.
|
|
44
|
-
|
|
45
|
-
---
|
|
46
|
-
|
|
47
|
-
## 1. What textus is
|
|
48
|
-
|
|
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.
|
|
50
|
-
|
|
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.
|
|
52
|
-
|
|
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.
|
|
54
|
-
|
|
55
|
-
### 1.1 Vocabulary axes
|
|
56
|
-
|
|
57
|
-
textus/4 names its concepts along six axes. Reviewers who internalize these can map any part of the spec to the right category:
|
|
58
|
-
|
|
59
|
-
- **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `converge`).
|
|
60
|
-
- **Place** — where data lives: lanes such as `knowledge`, `scratchpad`, `raw`, `proposals`, `artifacts`.
|
|
61
|
-
- **Thing** — what is stored: entries, fields, keys.
|
|
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`, …).
|
|
64
|
-
- **Rule** — constraints declared in the top-level `rules:` array of the manifest.
|
|
65
|
-
|
|
66
|
-
### 1.2 The five layers
|
|
67
|
-
|
|
68
|
-
textus is organized as five composable layers. Each layer has a single responsibility; later layers build on earlier ones.
|
|
69
|
-
|
|
70
|
-
| Layer | Name | Responsibility |
|
|
71
|
-
|---|---|---|
|
|
72
|
-
| L1 | **Store** | Plain-file backend: `.textus/data/<lane>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
|
|
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. |
|
|
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. |
|
|
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"`. |
|
|
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. |
|
|
77
|
-
|
|
78
|
-
## 2. Goals and non-goals
|
|
79
|
-
|
|
80
|
-
**Goals**
|
|
81
|
-
- Stable wire format (`textus/4`) any language can speak.
|
|
82
|
-
- Deterministic read/write of structured Markdown via a CLI returning JSON.
|
|
83
|
-
- Schema-validated frontmatter using YAML schemas as data.
|
|
84
|
-
- Capability-based write gates (roles hold capabilities; write authority per lane is derived from the role's capabilities and the lane's kind).
|
|
85
|
-
- Optimistic concurrency via ETags.
|
|
86
|
-
- Pure declarative data sources: produced entries acquire their data via workflow DSL steps; rendering (ERB) is a separate publish concern.
|
|
87
|
-
- Publish derived entries to well-known paths as body-only plain files.
|
|
88
|
-
- Plain-file backend — consumers can also read raw if they prefer.
|
|
89
|
-
|
|
90
|
-
**Non-goals**
|
|
91
|
-
- Not a database. No queries, indexes, joins, or full-text search.
|
|
92
|
-
- Not a graph store. Keys are hierarchical strings; cross-links are unindexed.
|
|
93
|
-
- Not a sync protocol. Single-writer per file, ETag-checked.
|
|
94
|
-
- Not a transport. Spawn the CLI or wrap it in MCP/HTTP downstream.
|
|
95
|
-
- Not a UI. Filesystem + CLI. Viewers ship elsewhere.
|
|
96
|
-
- Not a fetcher. textus declares sources; external automation invokes actions to materialize them.
|
|
97
|
-
- Not an executor. textus computes pure projections but never spawns shell commands.
|
|
98
|
-
|
|
99
|
-
## 3. Storage layout
|
|
100
|
-
|
|
101
|
-
The root is `.textus/` at the project working directory. A typical tree:
|
|
102
|
-
|
|
103
|
-
```
|
|
104
|
-
.textus/
|
|
105
|
-
manifest.yaml # internal: key → subtree mapping + role/lane declarations
|
|
106
|
-
schemas/ # internal: YAML schema files
|
|
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
|
|
110
|
-
audit.log # append-only NDJSON log of every successful write
|
|
111
|
-
sentinels/ # byte-copied publish bookkeeping (see §5.3)
|
|
112
|
-
data/ # ALL user content lives here
|
|
113
|
-
knowledge/ # lane: knowledge (kind: canon — author-holders write)
|
|
114
|
-
scratchpad/ # lane: scratchpad (kind: workspace — keep-holders write; agent's own durable lane)
|
|
115
|
-
proposals/ # lane: proposals (kind: queue — propose-holders write)
|
|
116
|
-
artifacts/ # lane: artifacts (kind: machine — converge-holders write)
|
|
117
|
-
raw/ # lane: raw (kind: raw — ingest-holders write; write-once)
|
|
118
|
-
```
|
|
119
|
-
|
|
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`).
|
|
121
|
-
|
|
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.
|
|
123
|
-
|
|
124
|
-
`.textus/audit.log` is an append-only NDJSON file written under a file lock by every successful `put`, `key_delete`, `key_mv`, and `accept`. Convergence (`drain`/`serve`) writes through these same verbs — a produced entry logs as `put`, a swept entry as `key_delete` — so there is no distinct `drain` audit verb. `.textus/role` (one line containing a role name) is optional and participates in the role-resolution order (§5).
|
|
125
|
-
|
|
126
|
-
### 3.1 Store location precedence
|
|
127
|
-
|
|
128
|
-
Implementations MUST resolve the store root in this order; the first match wins:
|
|
129
|
-
|
|
130
|
-
1. `--root <path>` flag passed to the CLI (or `root:` kwarg to `Store.discover`).
|
|
131
|
-
2. `TEXTUS_ROOT` environment variable.
|
|
132
|
-
3. Walk up from cwd looking for a `.textus/` directory containing `manifest.yaml`.
|
|
133
|
-
|
|
134
|
-
When (1) or (2) names a path that has no `manifest.yaml`, the CLI exits with `io_error` and a message naming the resolved absolute path. When (3) reaches the filesystem root without finding a store, the CLI exits with `io_error` naming the search start point.
|
|
135
|
-
|
|
136
|
-
## 4. Manifest
|
|
137
|
-
|
|
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).
|
|
139
|
-
|
|
140
|
-
```yaml
|
|
141
|
-
# .textus/manifest.yaml
|
|
142
|
-
version: textus/4
|
|
143
|
-
|
|
144
|
-
roles:
|
|
145
|
-
- { name: human, can: [author, propose] }
|
|
146
|
-
- { name: agent, can: [propose] }
|
|
147
|
-
- { name: automation, can: [converge] }
|
|
148
|
-
|
|
149
|
-
lanes:
|
|
150
|
-
- name: knowledge
|
|
151
|
-
kind: canon
|
|
152
|
-
- name: scratchpad
|
|
153
|
-
kind: workspace
|
|
154
|
-
owner: agent # optional, informational — agent's own lane
|
|
155
|
-
desc: "agent's durable working memory; bytes climb to knowledge only via propose→accept"
|
|
156
|
-
- name: proposals
|
|
157
|
-
kind: queue
|
|
158
|
-
- name: artifacts
|
|
159
|
-
kind: machine # machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)
|
|
160
|
-
|
|
161
|
-
entries:
|
|
162
|
-
- key: knowledge.identity.self
|
|
163
|
-
path: data/knowledge/identity/self.md
|
|
164
|
-
lane: knowledge
|
|
165
|
-
schema: identity
|
|
166
|
-
|
|
167
|
-
- key: knowledge.network.org
|
|
168
|
-
path: data/knowledge/network/org
|
|
169
|
-
lane: knowledge
|
|
170
|
-
schema: person
|
|
171
|
-
owner: human:network
|
|
172
|
-
nested: true
|
|
173
|
-
|
|
174
|
-
- key: artifacts.catalogs.people
|
|
175
|
-
path: data/artifacts/catalogs/people.md
|
|
176
|
-
lane: artifacts
|
|
177
|
-
schema: null
|
|
178
|
-
owner: automation:converge
|
|
179
|
-
|
|
180
|
-
rules:
|
|
181
|
-
- match: artifacts.feeds.**
|
|
182
|
-
retention: { ttl: 6h, action: archive }
|
|
183
|
-
|
|
184
|
-
audit:
|
|
185
|
-
max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
|
|
186
|
-
keep: 5 # rotated files to retain (default: 5)
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
Zone names are conventional — write authority comes from each lane's declared `kind:` crossed with the capabilities roles hold (§5); rename lanes freely.
|
|
190
|
-
|
|
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).
|
|
192
|
-
|
|
193
|
-
**Per-entry `format:`** an entry MAY declare `format:` to be one of `markdown` (default), `json`, `yaml`, or `text`. The `format` controls the on-disk shape and which path extension is required:
|
|
194
|
-
|
|
195
|
-
| `format` | Path extension | `template:` | `schema:` |
|
|
196
|
-
|------------|-----------------------------|------------------------|-----------|
|
|
197
|
-
| `markdown` | `.md` (or appended if absent) | required for produced | optional |
|
|
198
|
-
| `json` | `.json` required | optional (escape hatch) | optional (top-level keys) |
|
|
199
|
-
| `yaml` | `.yaml` or `.yml` required | optional (escape hatch) | optional (top-level keys) |
|
|
200
|
-
| `text` | `.txt` or no extension | required for produced | MUST be null |
|
|
201
|
-
|
|
202
|
-
For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema. Each matching file is enumerated as its own key, with the key segments derived from the path relative to the entry (extension stripped). A nested entry that instead mirrors a whole directory of files to a consumer path — without enumerating any of them as keys — uses a `{ tree: }` publish target (below); its files are opaque payload. (The former `index_filename:` directory-keyed enumeration was removed in 0.43.0 — ADR 0053.)
|
|
203
|
-
|
|
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.
|
|
205
|
-
|
|
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.
|
|
207
|
-
|
|
208
|
-
```yaml
|
|
209
|
-
- key: knowledge.skills
|
|
210
|
-
path: data/knowledge/skills
|
|
211
|
-
lane: knowledge
|
|
212
|
-
schema: skill
|
|
213
|
-
nested: true
|
|
214
|
-
publish:
|
|
215
|
-
- { tree: "skills" }
|
|
216
|
-
ignore: ["*.tmp", ".DS_Store"]
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
**Lookup rule:** to resolve a key, find the entry with the longest `key:` prefix that matches. If that entry has `nested: true`, the remaining segments map to subdirectories under its `path`. Otherwise the key must equal an entry exactly. The resolved filesystem path is `<.textus root>/<entry.path>[/<remaining>...].md` — manifest `path:` values include the `data/` prefix (e.g. `data/knowledge/network/org`).
|
|
220
|
-
|
|
221
|
-
## 5. Lanes and capability-based write gates
|
|
222
|
-
|
|
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.
|
|
224
|
-
|
|
225
|
-
The kind→verb mapping is closed:
|
|
226
|
-
|
|
227
|
-
| Lane `kind` | Required capability | Meaning |
|
|
228
|
-
|---|---|---|
|
|
229
|
-
| `canon` | `author` | Authored truth — only the trust anchor writes directly. |
|
|
230
|
-
| `workspace` | `keep` | Agent's own durable lane — bytes never auto-promote; climb to `canon` only via propose→accept. |
|
|
231
|
-
| `machine` | `converge` | Machine-maintained: computed outputs produced by `drain`. |
|
|
232
|
-
| `queue` | `propose` | Proposals awaiting promotion. |
|
|
233
|
-
| `raw` | `ingest` | Write-once external source material (URL bookmarks, files, assets). |
|
|
234
|
-
|
|
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.
|
|
236
|
-
|
|
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.
|
|
238
|
-
|
|
239
|
-
Default scaffold — Setup-1 (roles `human=[author, propose, ingest]`, `agent=[propose, keep, ingest]`, `automation=[converge, ingest]`):
|
|
240
|
-
|
|
241
|
-
| Lane | `kind` | Required capability | Writable by (default) | Use case |
|
|
242
|
-
|---|---|---|---|---|
|
|
243
|
-
| `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network. |
|
|
244
|
-
| `scratchpad` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
|
|
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. |
|
|
248
|
-
|
|
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).
|
|
250
|
-
|
|
251
|
-
Every lane MUST declare a `kind:` describing its role in the data-flow graph.
|
|
252
|
-
The vocabulary is closed: `canon` (authored truth), `workspace` (agent's own
|
|
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.
|
|
256
|
-
Because authority is derived, a manifest is rejected at load if it declares a
|
|
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.
|
|
263
|
-
|
|
264
|
-
### 5.1 Role resolution
|
|
265
|
-
|
|
266
|
-
The effective role for any CLI invocation is resolved in this order; the first match wins:
|
|
267
|
-
|
|
268
|
-
1. `--as=<role>` flag on the command line.
|
|
269
|
-
2. `TEXTUS_ROLE` environment variable.
|
|
270
|
-
3. `.textus/role` file (one line, role name) at the project root.
|
|
271
|
-
4. Default: `human`.
|
|
272
|
-
|
|
273
|
-
**Canonical roles (default scaffold):**
|
|
274
|
-
|
|
275
|
-
| Role | Capabilities (`can`) | Meaning |
|
|
276
|
-
|---|---|---|
|
|
277
|
-
| `human` | `[author, propose]` | Interactive user at a terminal; the single trust anchor. |
|
|
278
|
-
| `agent` | `[propose]` | Long-running AI or LLM process; stages proposals. |
|
|
279
|
-
| `automation` | `[converge]` | Scheduled or one-shot scripts: keep the `machine` lane current — pull external sources in and materialize computed outputs. |
|
|
280
|
-
|
|
281
|
-
Roles are declared in the manifest's `roles:` block (§5.1.1); the names above are the default mapping when `roles:` is omitted. Unknown role values are rejected with `invalid_role`.
|
|
282
|
-
|
|
283
|
-
Every successful write records the resolved role and a wall-clock timestamp in `.textus/audit.log`, so reviewers can later distinguish a human edit from an agent edit even though both live in the same file.
|
|
284
|
-
|
|
285
|
-
#### 5.1.1 Capabilities
|
|
286
|
-
|
|
287
|
-
Roles declare **capabilities** — verbs from a closed five-element set. A
|
|
288
|
-
manifest declares a `roles:` block mapping each role name to the capabilities
|
|
289
|
-
it holds via `can:`:
|
|
290
|
-
|
|
291
|
-
```yaml
|
|
292
|
-
roles:
|
|
293
|
-
- { name: owner, can: [author, propose] }
|
|
294
|
-
- { name: proposer, can: [propose] }
|
|
295
|
-
- { name: machine, can: [converge] }
|
|
296
|
-
- { name: keeper, can: [keep] }
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
Capability allow-list: `propose`, `author`, `keep`, `converge`, `ingest`. The mapping from
|
|
300
|
-
lane-kind to its required capability is a **bijection** (ADR 0091, which folded
|
|
301
|
-
the former `quarantine` + `derived` kinds back into one `machine` kind — undoing
|
|
302
|
-
the two-kind split of ADR 0090): each capability authorizes exactly one
|
|
303
|
-
lane-kind:
|
|
304
|
-
|
|
305
|
-
| Capability | Authorizes writes to lane-kind |
|
|
306
|
-
|---|---|
|
|
307
|
-
| `author` | `canon` |
|
|
308
|
-
| `keep` | `workspace` |
|
|
309
|
-
| `propose` | `queue` |
|
|
310
|
-
| `converge` | `machine` |
|
|
311
|
-
| `ingest` | `raw` |
|
|
312
|
-
|
|
313
|
-
A manifest naming a folded capability — `build` or the pre-0088
|
|
314
|
-
spelling `fetch` — in a `can:` list is rejected at load with a hint pointing to
|
|
315
|
-
`converge` (ADR 0090, 0091, 0111).
|
|
316
|
-
|
|
317
|
-
`author` is the single **trust anchor**: **at most one role may hold `author`**
|
|
318
|
-
(a manifest declaring two or more is rejected at load). The `accept` and
|
|
319
|
-
`reject` transitions also require the `author` capability — `accept` is a
|
|
320
|
-
transition verb, not a capability. Because write authority is derived, there is
|
|
321
|
-
no `write_policy:` — instead, every declared lane-kind's required verb MUST be
|
|
322
|
-
held by at least one role, or the manifest is rejected at load.
|
|
323
|
-
|
|
324
|
-
When the `roles:` block is omitted, the default mapping applies:
|
|
325
|
-
|
|
326
|
-
| Default name | Capabilities (`can`) |
|
|
327
|
-
|---|---|
|
|
328
|
-
| `human` | `[author, propose]` |
|
|
329
|
-
| `agent` | `[propose, keep]` |
|
|
330
|
-
| `automation` | `[converge]` |
|
|
331
|
-
|
|
332
|
-
Wire protocol `textus/4` is unchanged — capabilities are a manifest/semantics
|
|
333
|
-
concept and never appear on the wire.
|
|
334
|
-
|
|
335
|
-
Every write transition is authorized by **one Guard** (ADR 0031): an ordered
|
|
336
|
-
list of predicates over a single evaluation context. Predicate #0 of every write
|
|
337
|
-
guard is `zone_writable_by` (the capability gate above); the `author_held`
|
|
338
|
-
predicate keys on the `author` capability and is named `author_held` (it passes
|
|
339
|
-
when the acting role holds `author`). See §5.11 for composing extra predicates via
|
|
340
|
-
`rules[].guard:`.
|
|
341
|
-
|
|
342
|
-
### 5.2 Source layer (produced entries)
|
|
343
|
-
|
|
344
|
-
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).
|
|
345
|
-
|
|
346
|
-
#### 5.2.1 External source (`from: external`)
|
|
347
|
-
|
|
348
|
-
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.
|
|
349
|
-
|
|
350
|
-
```yaml
|
|
351
|
-
- key: artifacts.feeds.skills
|
|
352
|
-
lane: artifacts
|
|
353
|
-
kind: produced
|
|
354
|
-
format: json
|
|
355
|
-
source: { from: external, command: "true", sources: [] }
|
|
356
|
-
publish: [{ to: docs/reference/skills.md, template: feeds/skills.erb }]
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
A matching `Textus.workflow` block in `.textus/workflows/`:
|
|
360
|
-
|
|
361
|
-
```ruby
|
|
362
|
-
Textus.workflow "agentskills" do
|
|
363
|
-
match "artifacts.feeds.skills"
|
|
364
|
-
|
|
365
|
-
step :fetch do |_, _ctx|
|
|
366
|
-
# acquire data — return { "content" => { ... } }
|
|
367
|
-
{ "content" => { "skills" => [...], "count" => 1 } }
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
publish
|
|
371
|
-
end
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
`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.
|
|
375
|
-
|
|
376
|
-
**`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.
|
|
377
|
-
|
|
378
|
-
### 5.3 Publish layer (`publish:`)
|
|
379
|
-
|
|
380
|
-
Rendering and emission are a **publish** concern, orthogonal to acquire (§5.2). `publish:` is always a **list** of targets (ADR 0094). Each element is exactly one of two shapes:
|
|
381
|
-
|
|
382
|
-
- a **to-target** — `{ to: <path>, template?: <name> }` — emit the entry's data to one repo-relative path;
|
|
383
|
-
- a **tree-target** — `{ tree: <dir> }` — mirror the entry's stored subtree (ADR 0047).
|
|
384
|
-
|
|
385
|
-
The legacy *map* forms — `publish: { to: [...] }` and `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.
|
|
386
|
-
|
|
387
|
-
```yaml
|
|
388
|
-
publish:
|
|
389
|
-
- { to: CLAUDE.md, template: orientation.erb, inject_boot: true }
|
|
390
|
-
- { to: AGENTS.md, template: orientation.erb } # same data, its own render
|
|
391
|
-
- { to: .mcp.json } # no template → copy data verbatim
|
|
392
|
-
- { tree: skills/ } # subtree mirror (ADR 0047)
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
A **to-target** carries `to:` (required) and optionally `template:` / `inject_boot:`:
|
|
396
|
-
|
|
397
|
-
- **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.")
|
|
398
|
-
- **`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.
|
|
399
|
-
- **`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:`.
|
|
400
|
-
|
|
401
|
-
**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`.
|
|
402
|
-
|
|
403
|
-
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.
|
|
404
|
-
|
|
405
|
-
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.
|
|
406
|
-
|
|
407
|
-
**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).
|
|
408
|
-
|
|
409
|
-
**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.
|
|
410
|
-
|
|
411
|
-
### 5.4 Raw lane and ingest verb
|
|
412
|
-
|
|
413
|
-
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)
|
|
414
|
-
|
|
415
|
-
**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.
|
|
416
|
-
|
|
417
|
-
**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.
|
|
418
|
-
|
|
419
|
-
**Three source kinds:**
|
|
420
|
-
|
|
421
|
-
| Kind | Required fields | Stored content |
|
|
422
|
-
|------|----------------|----------------|
|
|
423
|
-
| `url` | `--url` | URL reference only (`body: null`) — a bookmark, never a content fetch |
|
|
424
|
-
| `file` | `--path` | File body text — use only for genuinely valuable content |
|
|
425
|
-
| `asset` | `--path`, `--zone` | Binary copied to `assets/raw/YYYY/MM/DD/<zone>/`; inline body is null |
|
|
426
|
-
|
|
427
|
-
**`access` field** — entries MAY carry `source.access: public | private` (field is `maintained_by: human`). Set `private` for sources not safe to reproduce publicly.
|
|
428
|
-
|
|
429
|
-
**Notebook stub** — every ingest creates a `scratchpad.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.
|
|
430
|
-
|
|
431
|
-
**Example — URL bookmark:**
|
|
432
|
-
|
|
433
|
-
```sh
|
|
434
|
-
textus ingest url agentskills-io-brainstorming \
|
|
435
|
-
--url=https://agentskills.io/skills/brainstorming \
|
|
436
|
-
--label="brainstorming skill" \
|
|
437
|
-
--as=agent
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
A `get` on a raw entry is a pure read — it returns the entry as stored and never re-fetches (ADR 0089).
|
|
441
|
-
|
|
442
|
-
**Raw entry shape.** Every raw-lane entry has a set of required fields
|
|
443
|
-
enforced by the format layer on write:
|
|
444
|
-
|
|
445
|
-
| Field | Required | Description |
|
|
446
|
-
|-------|----------|-------------|
|
|
447
|
-
| `ingested_at` | yes | ISO-8601 timestamp of ingestion |
|
|
448
|
-
| `content_hash` | yes | `sha256:<hex>` of the source content |
|
|
449
|
-
| `source.kind` | yes | One of `url`, `file`, `asset` |
|
|
450
|
-
| `source.url` | conditional | Required when `kind=url` |
|
|
451
|
-
| `source.label` | no | Human-readable label |
|
|
452
|
-
| `body` | no | Present only for `kind=file` |
|
|
453
|
-
| `asset` | no | Present only for `kind=asset` |
|
|
454
|
-
|
|
455
|
-
Tombstone entries (carrying `superseded_by:`) are exempt from shape
|
|
456
|
-
validation — they are stripped-down records pointing to the live entry.
|
|
457
|
-
|
|
458
|
-
### 5.5 Pending / accept workflow
|
|
459
|
-
|
|
460
|
-
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:
|
|
461
|
-
|
|
462
|
-
```yaml
|
|
463
|
-
---
|
|
464
|
-
proposal:
|
|
465
|
-
target_key: working.network.org.bob
|
|
466
|
-
action: put
|
|
467
|
-
_meta:
|
|
468
|
-
name: bob
|
|
469
|
-
relationship: peer
|
|
470
|
-
org: acme
|
|
471
|
-
---
|
|
472
|
-
Proposed body content.
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
`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).
|
|
476
|
-
|
|
477
|
-
`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.
|
|
478
|
-
|
|
479
|
-
### 5.6 Audit log
|
|
480
|
-
|
|
481
|
-
Every successful write appends one compact JSON object (NDJSON) to `.textus/audit.log`. The file is opened with `flock(LOCK_EX)` for the duration of each append so concurrent writers serialize cleanly.
|
|
482
|
-
|
|
483
|
-
Schema (one JSON object per line, no interior whitespace):
|
|
484
|
-
|
|
485
|
-
```json
|
|
486
|
-
{"seq":<integer>,"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
487
|
-
```
|
|
488
|
-
|
|
489
|
-
`seq` is a monotonic integer counter, auto-incremented on each append. It is the foundation for cursor-based queries: `textus audit --seq-since=N` returns only rows with `seq > N`, and `textus pulse --since=N` builds its `changed` array from the same cursor. When an agent's cursor falls below the oldest available seq (due to log rotation), the operation raises `CursorExpired`.
|
|
490
|
-
|
|
491
|
-
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `key_delete`, `accept`, `compute`, `key_mv`, ...; rows written before ADR 0082 used `delete`/`mv` — readers must accept both). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
|
|
492
|
-
|
|
493
|
-
For `key_mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
|
|
494
|
-
|
|
495
|
-
**Rotation.** After every successful append the implementation checks whether `audit.log` exceeds `max_size` bytes (checked inside the held `flock`, so the check sees the post-write size). If it does, the active log is rotated:
|
|
496
|
-
|
|
497
|
-
1. The seq range (`min_seq`, `max_seq`) of the active log is scanned, and a JSON sidecar (`audit.log.1.meta.json`) is written with those values plus a `rotated_at` ISO 8601 timestamp.
|
|
498
|
-
2. Existing rotated files are shifted: `audit.log.(N)` → `audit.log.(N+1)` for N = `keep-1` down to 1 (with their `.meta.json` sidecars).
|
|
499
|
-
3. `audit.log` is renamed to `audit.log.1`.
|
|
500
|
-
4. The file that would be shifted to `audit.log.(keep+1)` — i.e., `audit.log.keep` and its sidecar — is deleted before the shift.
|
|
501
|
-
5. The next append creates a fresh `audit.log` via `O_CREAT`. Seq numbering continues from the previous maximum; there is no reset.
|
|
502
|
-
|
|
503
|
-
Rotation is triggered by **byte size only** — there is no row-count or time-based trigger.
|
|
504
|
-
|
|
505
|
-
**Rotation knobs** (configured via the optional `audit:` block in `manifest.yaml`):
|
|
506
|
-
|
|
507
|
-
| Key | Default | Meaning |
|
|
508
|
-
|------------|--------------|---------|
|
|
509
|
-
| `max_size` | `10485760` | Maximum size of `audit.log` in bytes (10 MiB) before rotation is triggered. |
|
|
510
|
-
| `keep` | `5` | Number of rotated files retained on disk. When this limit is exceeded the oldest rotated file and its sidecar are deleted. |
|
|
511
|
-
|
|
512
|
-
Both keys are optional. Omitting `audit:` entirely uses the defaults above.
|
|
513
|
-
|
|
514
|
-
**`CursorExpired`.** When `audit --seq-since=N` or `pulse --since=N` is called with a cursor `N`, the implementation checks whether `N` is below the oldest sequence number still available on disk (`min_available_seq`, derived from the oldest retained rotated file's sidecar). The condition that raises `CursorExpired` is:
|
|
515
|
-
|
|
516
|
-
```
|
|
517
|
-
N < min_available_seq - 1
|
|
518
|
-
```
|
|
519
|
-
|
|
520
|
-
The error includes `requested` (the supplied cursor value) and `min_available` (the oldest seq still on disk).
|
|
521
|
-
|
|
522
|
-
**Recommended caller behavior on `CursorExpired`.** Call `textus boot` (without `--since`) to obtain a fresh `latest_seq` from the current audit log state, then resume `pulse` calls using that new cursor. Do not attempt to replay from an expired cursor — the intervening rows are gone.
|
|
523
|
-
|
|
524
|
-
### 5.7 Security bounds
|
|
525
|
-
|
|
526
|
-
textus enforces fixed bounds to keep behavior predictable under hostile or buggy input:
|
|
527
|
-
|
|
528
|
-
- **Projection result:** 1000 entries (hard cap).
|
|
529
|
-
- **Template recursion:** depth 8.
|
|
530
|
-
- **Manifest size:** 256 KB.
|
|
531
|
-
- **Entry size:** 1 MB.
|
|
532
|
-
- **Audit log:** unbounded; rotation is the user's problem.
|
|
533
|
-
|
|
534
|
-
### 5.8 Schema evolution
|
|
535
|
-
|
|
536
|
-
Schemas may declare per-field ownership and version history. The `fields:` and `evolution:` blocks are both optional; a schema may omit them and still parse.
|
|
537
|
-
|
|
538
|
-
**`fields:` block** — keyed by field name. Each entry is an object with at least `type`, plus optional `maintained_by` and any vendor extensions:
|
|
539
|
-
|
|
540
|
-
```yaml
|
|
541
|
-
fields:
|
|
542
|
-
full_name: { type: string, maintained_by: human }
|
|
543
|
-
embedding: { type: array, maintained_by: agent }
|
|
544
|
-
updated_at: { type: time, maintained_by: automation }
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
`maintained_by` values are free-form role-name strings (e.g. `human | agent | automation`). They name the role expected to own a field; values that match no declared role do not affect role-authority validation and pass through unchanged.
|
|
548
|
-
|
|
549
|
-
**`evolution:` block** — top-level, declares the schema's history and migration intent:
|
|
550
|
-
|
|
551
|
-
```yaml
|
|
552
|
-
evolution:
|
|
553
|
-
added_in: 2026-05-19
|
|
554
|
-
deprecated_at: null
|
|
555
|
-
migrate_from:
|
|
556
|
-
OLD_FIELD: NEW_FIELD
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
`textus schema migrate NAME` consults `evolution.migrate_from` when invoked without `--rename=OLD:NEW`, applying every declared rename across affected entries in one pass. An explicit `--rename` flag overrides the schema-declared map for that invocation.
|
|
560
|
-
|
|
561
|
-
**Defaults:** when `fields:` and `evolution:` are absent, `schema.maintained_by(field)` returns `nil` for every field and `schema.evolution` returns `{}`.
|
|
562
|
-
|
|
563
|
-
**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`.
|
|
564
|
-
|
|
565
|
-
### 5.9 Rules
|
|
566
|
-
|
|
567
|
-
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:
|
|
568
|
-
|
|
569
|
-
```yaml
|
|
570
|
-
rules:
|
|
571
|
-
- match: feeds.**
|
|
572
|
-
retention: { ttl: 90d, action: archive }
|
|
573
|
-
|
|
574
|
-
- match: feeds.calendar.**
|
|
575
|
-
handler_permit: [ical-events]
|
|
576
|
-
|
|
577
|
-
- match: proposals.**
|
|
578
|
-
guard:
|
|
579
|
-
accept: [schema_valid, author_held]
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
**Slots (all optional within a block):**
|
|
583
|
-
|
|
584
|
-
| Slot | Type | Meaning |
|
|
585
|
-
|---|---|---|
|
|
586
|
-
| `retention` | `{ ttl, action: drop\|archive }` | Age-based garbage collection (ADR 0093). `action` is `drop` (delete the entry) or `archive` (copy to `<store>/archive/<relative-path>` then delete). Age is measured from `_meta.last_fetched_at` (intake entries) when present, else the leaf file's modification time. **Destructive — applied only on the convergence sweep (the destructive phase of `drain`/`serve`), never on a write or read.** Orthogonal to production: an intake entry may declare both `source: { ..., ttl: 1h }` (re-pull cadence) and a `retention: { ttl: 90d, action: archive }` rule. `retention:` on a `derived` entry is rejected at load. |
|
|
587
|
-
| `handler_permit` | list of strings | Constrains which `source.handler:` names may be used by intake entries matched by this block. Enforced by `textus doctor`. |
|
|
588
|
-
| `guard` | `{ <transition>: [predicates] }` | Extra predicates composed (AND) onto a write transition's built-in **base** guard (ADR 0031). Keyed by transition (`put`, `key_delete`, `key_mv`, `accept`, `reject`, `converge`). Predicate names are drawn from the closed vocabulary (`zone_writable_by`, `schema_valid`, `author_held`, `target_is_canon`, `etag_match`, `fresh_within`); parameterized predicates use `{ name: param }` form, e.g. `{ fresh_within: "1h" }`. Enforced — the transition refuses (`guard_failed`) if any predicate fails; the topology refusal keeps the `write_forbidden` code. |
|
|
589
|
-
|
|
590
|
-
The `retention:` slot handles age-based GC only. Write-trigger strategy for derived entries (`on_write: sync|async`) is declared on the entry's own `source:` block (§5.2.1), not in `rules:`. Generator/build drift — a derived entry whose sources changed since its `generated.at` — is reported by the `textus doctor` `generator_drift` check rather than any rule slot.
|
|
591
|
-
|
|
592
|
-
**Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
|
|
593
|
-
|
|
594
|
-
**Resolution.** For each key textus computes a `RuleSet { 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`).
|
|
595
|
-
|
|
596
|
-
**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).
|
|
597
|
-
|
|
598
|
-
### 5.10 Storage formats
|
|
599
|
-
|
|
600
|
-
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.
|
|
601
|
-
|
|
602
|
-
- **markdown** — YAML frontmatter between `---` fences, free-form body. Parse: Psych `safe_load` on the frontmatter block; body is the remainder. Serialize: emit `---\n<yaml>\n---\n<body>`. `content` is always `nil`. `_meta` holds the parsed frontmatter hash.
|
|
603
|
-
- **json** — entire file is a JSON document. Parse: `JSON.parse`. Serialize: `JSON.pretty_generate(content)` + trailing newline. `_meta` is populated from the top-level `_meta` hash (if present, else `{}`); `body` is the raw bytes; `content` is the parsed object with `_meta` stripped.
|
|
604
|
-
- **yaml** — entire file is a YAML mapping. Parse: `YAML.safe_load(bytes, permitted_classes: [Date, Time], aliases: false)`; anchors/aliases rejected. Serialize: `YAML.dump(content).sub(/\A---\n/, "")`. Same `_meta` / `body` / `content` rules as JSON.
|
|
605
|
-
- **text** — raw UTF-8 bytes. Parse: body is the file verbatim, `_meta` is `{}`, `content` is `nil`. Serialize: write `body` bytes (with trailing newline if missing).
|
|
606
|
-
|
|
607
|
-
**Envelope shape.** Every envelope carries `format:` (always present, defaults to `markdown` for back-compat). For `json|yaml`, the envelope additionally carries `content:` (parsed object). `body` is always the raw on-disk bytes. `_meta` always exists in the envelope: for `markdown` it holds the parsed YAML frontmatter; for `json|yaml` it mirrors the top-level `_meta` block (`{}` if absent); for `text` it is `{}`.
|
|
608
|
-
|
|
609
|
-
**`_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:
|
|
610
|
-
|
|
611
|
-
```
|
|
612
|
-
from, template, transform
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
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.
|
|
616
|
-
|
|
617
|
-
## 6. Schemas
|
|
618
|
-
|
|
619
|
-
Schemas live in `.textus/schemas/<name>.yaml`. A schema declares the required and optional frontmatter fields for entries that reference it.
|
|
620
|
-
|
|
621
|
-
```yaml
|
|
622
|
-
# .textus/schemas/person.yaml
|
|
623
|
-
name: person
|
|
624
|
-
required:
|
|
625
|
-
- name
|
|
626
|
-
- relationship
|
|
627
|
-
- org
|
|
628
|
-
optional:
|
|
629
|
-
- notes
|
|
630
|
-
- aliases
|
|
631
|
-
fields:
|
|
632
|
-
name: { type: string, max: 80 }
|
|
633
|
-
relationship: { type: enum, values: [peer, manager, report, external] }
|
|
634
|
-
org: { type: string }
|
|
635
|
-
aliases: { type: array, items: { type: string } }
|
|
636
|
-
notes: { type: string, max: 2000 }
|
|
637
|
-
```
|
|
638
|
-
|
|
639
|
-
**Supported types:** `string`, `number`, `boolean`, `enum` (with `values:`), `array` (with `items:`), `object` (with nested `fields:`).
|
|
640
|
-
|
|
641
|
-
**Validation:** strict required-field check; optional fields may be omitted; unknown fields produce a warning, not an error (forward-compat).
|
|
642
|
-
|
|
643
|
-
## 7. Entry file format
|
|
644
|
-
|
|
645
|
-
Every entry is a UTF-8 Markdown file with a YAML frontmatter block:
|
|
646
|
-
|
|
647
|
-
```markdown
|
|
648
|
-
---
|
|
649
|
-
name: jane
|
|
650
|
-
relationship: peer
|
|
651
|
-
org: acme
|
|
652
|
-
---
|
|
653
|
-
Short body in Markdown.
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
The frontmatter `name:` field, when present, must match the file's basename (without `.md`). Implementations may relax this for backward compat but the reference impl enforces it.
|
|
657
|
-
|
|
658
|
-
**`uid:` (Textus UID).** Entries MAY carry a stable identity field that survives renames and moves. Optional. When present:
|
|
659
|
-
|
|
660
|
-
- Lives at top-level `uid:` in markdown frontmatter, or `_meta.uid` in `json`/`yaml` entries.
|
|
661
|
-
- Format: lowercase hex string, 12 or more characters. The reference impl mints 16 hex chars (`SecureRandom.hex(8)`). This is a **Textus UID**, not a UUID — short on purpose.
|
|
662
|
-
- Auto-assigned on the first successful `Store#put` if the payload has no uid. Preserved on subsequent puts.
|
|
663
|
-
- Existing files without a uid continue to work. The envelope shows `"uid": null` until a put mints one.
|
|
664
|
-
- `text` entries have no metadata channel and therefore no uid; their envelope always shows `"uid": null`.
|
|
665
|
-
|
|
666
|
-
**`sources:` (Source references).** Entries MAY carry a `sources` array in
|
|
667
|
-
their frontmatter to declare external provenance. Each element is a raw-lane
|
|
668
|
-
key string (starting with `raw.`). The array is preserved on write —
|
|
669
|
-
existing sources carry forward if no new `sources` are provided. The
|
|
670
|
-
envelope returns `sources` as a top-level array when non-empty; omitted
|
|
671
|
-
when absent.
|
|
672
|
-
|
|
673
|
-
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.
|
|
674
|
-
|
|
675
|
-
## 8. Envelope (the wire format)
|
|
676
|
-
|
|
677
|
-
Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
678
|
-
|
|
679
|
-
```json
|
|
680
|
-
{
|
|
681
|
-
"protocol": "textus/4",
|
|
682
|
-
"key": "knowledge.network.org.jane",
|
|
683
|
-
"lane": "knowledge",
|
|
684
|
-
"owner": "human:network",
|
|
685
|
-
"path": "/absolute/path/to/.textus/data/knowledge/network/org/jane.md",
|
|
686
|
-
"format": "markdown",
|
|
687
|
-
"_meta": { "name": "jane", "relationship": "peer", "org": "acme" },
|
|
688
|
-
"body": "Short body in Markdown.\n",
|
|
689
|
-
"etag": "sha256:8f3c…",
|
|
690
|
-
"schema_ref": "person",
|
|
691
|
-
"uid": "a1b2c3d4e5f60718",
|
|
692
|
-
"sources": [
|
|
693
|
-
{ "key": "raw.2026.06.20.url-mcp-spec", "etag": "sha256:1a2b…", "suspended": false }
|
|
694
|
-
],
|
|
695
|
-
"stale": false,
|
|
696
|
-
"stale_reason": null,
|
|
697
|
-
"fetching": false
|
|
698
|
-
}
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
**Field rules:**
|
|
702
|
-
- `protocol` MUST be the exact string `textus/4`.
|
|
703
|
-
- `key` MUST be the canonical resolved key.
|
|
704
|
-
- `lane` MUST be one of the lanes declared in the manifest (`knowledge`, `scratchpad`, `proposals`, `artifacts`, `raw` in the default Setup-1 scaffold).
|
|
705
|
-
- `path` MUST be an absolute filesystem path.
|
|
706
|
-
- `format` MUST be one of `markdown`, `json`, `yaml`, `text` (§5.12). Absent envelopes are treated as `markdown` for back-compat.
|
|
707
|
-
- `body` is the raw on-disk bytes as a UTF-8 string for every format.
|
|
708
|
-
- `content` is present only when `format` is `json` or `yaml`; equals the parsed object. For `json|yaml`, `_meta` mirrors the top-level `_meta` block (or `{}` if absent). For `markdown`, `_meta` holds the parsed YAML frontmatter. For `text`, `_meta` is `{}`.
|
|
709
|
-
- `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
|
|
710
|
-
- `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
|
|
711
|
-
- `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
|
|
712
|
-
- `sources` is an array of source objects. Each object has `key` (the referenced entry's key), `etag` (sha256 snapshot taken at write time, or absent when no snapshot exists), and `suspended` (`true` when the referenced entry's current on-disk etag differs from the stored snapshot — the source changed after this entry was last written). Present only when non-empty.
|
|
713
|
-
- `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.
|
|
714
|
-
- `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`.
|
|
715
|
-
- `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.
|
|
716
|
-
|
|
717
|
-
> **Note:** `list`/`where` envelopes do **not** include `stale`, `stale_reason`, or `fetching` — freshness annotation is only provided by `get`.
|
|
718
|
-
|
|
719
|
-
Errors use a distinct envelope:
|
|
720
|
-
|
|
721
|
-
```json
|
|
722
|
-
{
|
|
723
|
-
"protocol": "textus/4",
|
|
724
|
-
"ok": false,
|
|
725
|
-
"code": "write_forbidden",
|
|
726
|
-
"message": "writing 'knowledge.identity.self' (lane 'knowledge') needs capability 'author'",
|
|
727
|
-
"hint": "held by: human; pass --as=<role>",
|
|
728
|
-
"details": { "key": "knowledge.identity.self", "lane": "knowledge", "verb": "author", "holders": ["human"] }
|
|
729
|
-
}
|
|
730
|
-
```
|
|
731
|
-
|
|
732
|
-
**Error codes:**
|
|
733
|
-
|
|
734
|
-
| Code | Meaning | Default exit |
|
|
735
|
-
|---|---|---|
|
|
736
|
-
| `unknown_key` | Key does not resolve | 1 |
|
|
737
|
-
| `bad_frontmatter` | YAML parse failed or `name:` mismatch | 1 |
|
|
738
|
-
| `schema_violation` | Required field missing or wrong type | 1 |
|
|
739
|
-
| `write_forbidden` | Resolved role lacks the capability the lane-kind requires | 1 |
|
|
740
|
-
| `etag_mismatch` | Concurrent write detected | 1 |
|
|
741
|
-
| `io_error` | Filesystem failure | 64 |
|
|
742
|
-
| `usage` | CLI argument error | 2 |
|
|
743
|
-
|
|
744
|
-
## 9. CLI surface
|
|
745
|
-
|
|
746
|
-
The reference binary is `textus`. Conforming implementations MAY use any binary name; the protocol is in the JSON.
|
|
747
|
-
|
|
748
|
-
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.
|
|
749
|
-
|
|
750
|
-
| Verb | Reads / writes | Role required |
|
|
751
|
-
|---|---|---|
|
|
752
|
-
| `list [--prefix=K] [--lane=Z]` | read | any |
|
|
753
|
-
| `where K` | read | any |
|
|
754
|
-
| `get K` | read (a pure on-disk read annotated with a freshness verdict; never refreshes — ADR 0089) | any |
|
|
755
|
-
| `schema show K` | read | any |
|
|
756
|
-
| `audit [--key=K] [--lane=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | read | any |
|
|
757
|
-
| `blame KEY` | read | any |
|
|
758
|
-
| `rule list` / `rule explain KEY` | read | any |
|
|
759
|
-
| `deps K` / `rdeps K` | read | any |
|
|
760
|
-
| `published` | read | any |
|
|
761
|
-
| `hook list` | read | any |
|
|
762
|
-
| `hook run NAME` | write | any |
|
|
763
|
-
| `doctor [--check=NAME[,NAME]] [--output=json]` | read | any |
|
|
764
|
-
| `boot [--output=json]` | read | any |
|
|
765
|
-
| `pulse [--since=N]` | read | any |
|
|
766
|
-
| `put K --stdin --as=R` | write (stores the stdin JSON; runs no handler — ADR 0089) | per lane |
|
|
767
|
-
| `propose K --stdin --as=R` | write | `propose`-holder (auto-prefixes propose_zone) |
|
|
768
|
-
| `key delete K --if-etag=E --as=R` | write | per lane |
|
|
769
|
-
| `drain [--prefix=K] [--lane=Z]` | write | `converge`-holder (typically `automation`) |
|
|
770
|
-
| `serve [--poll=SECS]` | write (long-lived daemon) | `converge`-holder (typically `automation`) |
|
|
771
|
-
| `jobs [--state=ready\|leased\|done\|failed] [--action=retry\|purge] [--job-id=ID]` | read | any |
|
|
772
|
-
| `accept K --as=human` | write | `author`-holder (typically `human`) |
|
|
773
|
-
| `reject K --as=human` | write | `author`-holder (typically `human`) |
|
|
774
|
-
| `init` | write | `human` |
|
|
775
|
-
| `schema {show,init,diff,migrate}` | read/write | `human` for writes |
|
|
776
|
-
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per lane (same-lane only) |
|
|
777
|
-
| `key uid K` | read | any |
|
|
778
|
-
|
|
779
|
-
**`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:
|
|
780
|
-
|
|
781
|
-
```json
|
|
782
|
-
{
|
|
783
|
-
"agent_quickstart": {
|
|
784
|
-
"read_verbs": ["get", "list", "pulse", "schema_show", "boot", "rule_explain", "where", "deps", "rdeps"],
|
|
785
|
-
"write_verbs": ["accept", "key_delete", "key_mv", "propose", "put", "reject"],
|
|
786
|
-
"writable_lanes": ["proposals"],
|
|
787
|
-
"propose_lane": "proposals",
|
|
788
|
-
"latest_seq": 1842
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
```
|
|
792
|
-
|
|
793
|
-
`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 retention/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`doctor`, nor `freshness` (the Ruby-only internal lifecycle scan, ADR 0085) (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).
|
|
794
|
-
|
|
795
|
-
The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `data_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). The structural mutation verbs (`key_mv`, `key_mv_prefix`, `key_delete_prefix`, `data_mv`) accept `dry_run: true` as an opt-in preview that returns a Plan without mutating (ADR 0071). `drain` does not support `dry_run` (it is async-only, ADR 0110). Single-key `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. `drain` is also on MCP (ADR 0076, ADR 0087, ADR 0110): it is caller-agnostic and its produce jobs self-elevate — materialization always runs as the manifest's `converge`-capable actor regardless of the calling role, granting no authority over content (materialization is a pure, idempotent function of already-accepted canon, ADR 0070); the destructive retention sweep runs as the caller. Each produce job self-acquires the single-writer build lock, so a concurrent CLI, reactive, or background pass cannot collide with an MCP-triggered one — a held lock is a graceful soft-miss (ADR 0110).
|
|
796
|
-
|
|
797
|
-
`latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
|
|
798
|
-
|
|
799
|
-
**`textus pulse` output shape:**
|
|
800
|
-
|
|
801
|
-
```json
|
|
802
|
-
{
|
|
803
|
-
"cursor": 1845,
|
|
804
|
-
"changed": [ { "seq": 1843, "key": "knowledge.notes.x", "verb": "put", "role": "human", "ts": "..." } ],
|
|
805
|
-
"pending_review": [ "proposals.proposal.123" ],
|
|
806
|
-
"contract_etag": "sha256:1f3a…",
|
|
807
|
-
"index_etag": "sha256:8f3c…"
|
|
808
|
-
}
|
|
809
|
-
```
|
|
810
|
-
|
|
811
|
-
`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`.
|
|
812
|
-
|
|
813
|
-
**`put` input** (read from stdin when `--stdin` is given):
|
|
814
|
-
|
|
815
|
-
```json
|
|
816
|
-
{ "_meta": { "name": "jane", "relationship": "peer", "org": "acme" },
|
|
817
|
-
"body": "Short body.\n",
|
|
818
|
-
"if_etag": "sha256:8f3c…" }
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
`if_etag` is optional on both `put` and `key_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).
|
|
822
|
-
|
|
823
|
-
The lifecycle scan runs per-entry at `get` time — each `get` response carries `stale`/`stale_reason` when the entry has a TTL rule. ADR 0085 removed the standalone `freshness` verb; 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). `pulse` does not include a `stale` list. `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`.
|
|
824
|
-
|
|
825
|
-
`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`.
|
|
826
|
-
|
|
827
|
-
`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.
|
|
828
|
-
|
|
829
|
-
`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.
|
|
830
|
-
|
|
831
|
-
`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.
|
|
832
|
-
|
|
833
|
-
## 10. ETag semantics
|
|
834
|
-
|
|
835
|
-
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.
|
|
836
|
-
## 10.1 Errors carry hints
|
|
837
|
-
|
|
838
|
-
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.
|
|
839
|
-
|
|
840
|
-
## 10.2 `textus doctor`
|
|
841
|
-
|
|
842
|
-
`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.
|
|
843
|
-
|
|
844
|
-
## 11. Versioning
|
|
845
|
-
|
|
846
|
-
- The current wire string is `textus/4`.
|
|
847
|
-
- Backward-compatible additions (new fields, new error codes, new schema types) MAY be made under `textus/4`.
|
|
848
|
-
- Breaking changes (renamed/removed envelope fields, lane semantics, key grammar) require a new wire string `textus/4`.
|
|
849
|
-
- Implementations MUST reject envelopes whose `protocol` they do not recognize.
|
|
850
|
-
|
|
851
|
-
The reference Ruby gem follows semver independently and speaks `textus/4`.
|
|
852
|
-
## 11.1 Agent integration
|
|
853
|
-
|
|
854
|
-
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.
|
|
855
|
-
|
|
856
|
-
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).
|
|
857
|
-
|
|
858
|
-
## 12. Conformance fixtures
|
|
859
|
-
|
|
860
|
-
A conformant implementation MUST pass these fixtures (the reference test suite ships a YAML file listing inputs and expected envelopes):
|
|
861
|
-
|
|
862
|
-
**Fixture A — Resolve and read:**
|
|
863
|
-
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.
|
|
864
|
-
|
|
865
|
-
**Fixture B — Role gate on write:**
|
|
866
|
-
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.
|
|
867
|
-
|
|
868
|
-
**Fixture C — Schema violation:**
|
|
869
|
-
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.
|
|
870
|
-
|
|
871
|
-
**Fixture D — Staleness detection:**
|
|
872
|
-
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.
|
|
873
|
-
|
|
874
|
-
**Fixture E — Workflow produce:**
|
|
875
|
-
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.
|
|
876
|
-
|
|
877
|
-
**Fixture F — ERB render at publish:**
|
|
878
|
-
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.
|
|
879
|
-
|
|
880
|
-
**Fixture G — Copy publish:**
|
|
881
|
-
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.
|
|
882
|
-
|
|
883
|
-
**Fixture H — Audit log format:**
|
|
884
|
-
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.
|
|
885
|
-
|
|
886
|
-
**Fixture I — Pending → accept:**
|
|
887
|
-
Given a proposal entry `proposals.knowledge.self.patch` proposing a change to `knowledge.identity.self`, `textus accept proposals.knowledge.self.patch --as=human` copies the patch body into the target key, deletes the proposal entry, and appends two audit lines (one for the target write, one for the proposals delete) in that order.
|
|
888
|
-
|
|
889
|
-
## 13. Why not X?
|
|
890
|
-
|
|
891
|
-
- **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.
|
|
892
|
-
|
|
893
|
-
- **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.
|
|
894
|
-
|
|
895
|
-
- **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.
|
|
896
|
-
|
|
897
|
-
- **Why not Notion / Coda?** Closed, hosted, lossy export. textus is local-first, plain-files, diffable in git.
|
|
898
|
-
|
|
899
|
-
- **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`.
|
|
900
|
-
|
|
901
|
-
- **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.
|
|
902
|
-
|
|
903
|
-
- **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.
|
|
904
|
-
## 13.1 Layered architecture (internal)
|
|
905
|
-
|
|
906
|
-
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.
|
|
907
|
-
|
|
908
|
-
See [`docs/architecture/README.md`](docs/architecture/README.md) for an ASCII diagram and the full read-path walkthrough.
|
|
909
|
-
|