textus 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 613e04300389a5f5bf058f4e75c15b5ff19f813fb7ef6102ba38ce53ecd43043
4
- data.tar.gz: 97d121b40ac753af1e04893429db1abfe4044f791fff5ea02de33910a4cfa00c
3
+ metadata.gz: 35d3b6540fc043048133df0874e76b36e522d844da783b69a5e8993418157ae4
4
+ data.tar.gz: e0293b87efb32c1edf2d6c0103dcd94289d91031a4be570f8fdd40fb681c1e79
5
5
  SHA512:
6
- metadata.gz: 13d11e92b35b952fc9974293544ab49d50dc6055f7ee80771b33a82f511ba534f6cf3fa8c26a84e886778f6b5ee662a3a540d36c7072c23f2b366540a365e066
7
- data.tar.gz: 7cbf43d42e37271b73122d5db10a463a1936c39696cc6a5e7eee56646ccdb1c503a645e63b3b40e09bfc911ae531e2258b02d4e715458bcd54a193d9e7ca5b0d
6
+ metadata.gz: '039b6f44941ea52ac8d956297d9619c4cc8b2346e7d8bced24bc5671506726a44348da66791aa6d032e56dd403353d1b34e9b2fee37eaec05cd6c83d5defc715'
7
+ data.tar.gz: e6e5e8f97f5f9a07a03813d61f9efa2088fb8f1338af89ba1298bf307c6c72e89f1028aa5a67ffdf8f97f2151c0af1273f82ff80751c59c11aa93ef2953323a9
data/CHANGELOG.md CHANGED
@@ -9,6 +9,71 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.20.2 — 2026-05-27
13
+
14
+ ### Fixed
15
+ - Promotion predicate `accept_authority_signed` now checks the role's *kind*
16
+ via `manifest.role_kind`, so manifests with a renamed authority role (e.g.
17
+ `owner` instead of `human`) pass the promotion gate. The internal class
18
+ `Predicates::HumanAccept` was renamed to `Predicates::AcceptAuthoritySigned`.
19
+ - `textus schema migrate` now writes as the manifest's declared
20
+ `accept_authority` role instead of the literal `"human"`, and raises a
21
+ clear `UsageError` (with a YAML hint) when no `accept_authority` role is
22
+ declared.
23
+ - `textus accept` / `textus reject` no longer claim "only human role can
24
+ accept" when the manifest declares zero `accept_authority` roles — the
25
+ error now says "no role with accept_authority kind is declared in this
26
+ manifest; accept/reject is disabled".
27
+ - `textus build` now resolves the build role from the manifest's declared
28
+ `generator` kind instead of hardcoding `"builder"`, so renamed generator
29
+ roles work correctly.
30
+ - Manifest validator's "exactly one accept_authority" error message now
31
+ matches what the schema actually enforces.
32
+
33
+ ### Removed
34
+ - Legacy `human_accept` promotion-predicate alias (string and symbol forms).
35
+ Manifests using `rules[].promotion.requires: [human_accept]` must change
36
+ to `[accept_authority_signed]`. The error on the old form is actionable:
37
+ `unknown promotion predicate: 'human_accept' (known: schema_valid,
38
+ accept_authority_signed)`.
39
+ - `textus key normalize` verb and the underlying
40
+ `Textus::Application::Tools::MigrateKeys` module. Files dropped into nested
41
+ zones with illegal basenames are still reported by `textus doctor` with a
42
+ `key.illegal` finding; fix them by hand. The `--upgrade-manifest` flag and
43
+ its `Textus::Application::Tools::MigrateManifestToKinds` module (one-shot
44
+ 0.19→0.20 manifest upgrader) are removed for the same reason — dead weight.
45
+ - The `migrate-keys` audit-log payload string is no longer emitted (no writer
46
+ produces it).
47
+
48
+ ### Internal
49
+ - Final cleanup of role-name leaks identified by the 0.20.2 architecture
50
+ audit (follow-on to 0.20.1 role-kinds refactor).
51
+
52
+ ## 0.20.1 — 2026-05-27
53
+
54
+ ### Added
55
+ - Optional `roles:` block in `manifest.yaml` lets users rename roles without
56
+ breaking engine semantics. Each declared role maps to one of four engine
57
+ kinds: `accept_authority`, `generator`, `proposer`, `runner`. (#72)
58
+ - `Manifest#role_kind`, `Manifest#roles_with_kind`, `Manifest#zone_kinds`
59
+ accessors for engine integrations.
60
+
61
+ ### Changed
62
+ - `accept` / `reject` now gate on `accept_authority` kind, not the literal
63
+ `"human"` role. Error messages cite the configured role name.
64
+ - `validator` last-writer trust check uses `accept_authority` kind.
65
+ - Entry `in_generator_zone?` / `in_proposal_zone?` query `zone_kinds`.
66
+ - `Intro` derives `write_flows` and `agent_protocol.role_resolution.roles`
67
+ from the manifest's role mapping.
68
+ - Promote DSL predicate `:human_accept` renamed to `:accept_authority_signed`;
69
+ the old symbol still works as an alias.
70
+ - Schema rejects zone writers that reference an undeclared role when `roles:`
71
+ is declared.
72
+
73
+ ### Compatibility
74
+ - No wire protocol change (`textus/3`).
75
+ - Existing manifests without a `roles:` block behave identically to 0.20.0.
76
+
12
77
  ## 0.20.0 — architecture redesign (2026-05-27)
13
78
 
14
79
  **BREAKING (pre-1.0):** Public top-level utility modules removed,
data/SPEC.md CHANGED
@@ -229,6 +229,40 @@ Unknown role values are rejected with `invalid_role`.
229
229
 
230
230
  Every successful write records the resolved role and a wall-clock timestamp in `.textus/audit.log`, so reviewers can later distinguish a human edit from an agent edit even though both live in the same file.
231
231
 
232
+ #### 5.1.1 Role kinds (engine semantics)
233
+
234
+ Internally the engine recognizes four **role kinds** — abstract capability
235
+ markers — rather than the four default role names. A manifest may declare a
236
+ `roles:` block to map any role name to a kind:
237
+
238
+ ```yaml
239
+ roles:
240
+ - { name: owner, kind: accept_authority }
241
+ - { name: compiler, kind: generator }
242
+ - { name: proposer, kind: proposer }
243
+ - { name: fetcher, kind: runner }
244
+ ```
245
+
246
+ Kind allow-list: `accept_authority`, `generator`, `proposer`, `runner`.
247
+ At most one role may have `accept_authority`. When `roles:` is declared,
248
+ every entry in `zones[*].write_policy` must be a declared role name.
249
+
250
+ When the `roles:` block is omitted, the default mapping applies:
251
+
252
+ | Default name | Kind |
253
+ |---|---|
254
+ | `human` | `accept_authority` |
255
+ | `agent` | `proposer` |
256
+ | `builder` | `generator` |
257
+ | `runner` | `runner` |
258
+
259
+ This means existing manifests continue to work byte-for-byte. Wire protocol
260
+ `textus/3` is unchanged — kinds are an internal-semantics concept and never
261
+ appear on the wire.
262
+
263
+ The promotion DSL predicate `:human_accept` is now `:accept_authority_signed`;
264
+ the old symbol works as an alias for backwards compatibility.
265
+
232
266
  ### 5.2 Compute layer (derived entries)
233
267
 
234
268
  Derived entries live in a zone whose `write_policy:` list includes `builder` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry declares a `compute:` block with a `kind:` discriminator.
@@ -399,7 +433,7 @@ Schema (one JSON object per line, no interior whitespace):
399
433
  {"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
400
434
  ```
401
435
 
402
- `ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `migrate-keys`, `mv`, ...). Note that `migrate-keys` here is the on-disk payload key — the CLI surface is `textus key migrate`; the payload string is retained for log stability. `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). `key migrate --write` emits one line per renamed file (with payload `verb: "migrate-keys"`) using the new key as `key` and the file's pre- and post-rename etags.
436
+ `ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `mv`, ...). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
403
437
 
404
438
  For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
405
439
 
@@ -705,7 +739,6 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
705
739
  | `init` | write | `human` |
706
740
  | `schema {show,init,diff,migrate}` | read/write | `human` for writes |
707
741
  | `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
708
- | `key normalize [--dry-run\|--write]` | write (with `--write`) | `human` |
709
742
  | `key uid K` | read | any |
710
743
 
711
744
  **`put` input** (read from stdin when `--stdin` is given):
@@ -0,0 +1,33 @@
1
+ module Textus
2
+ module Application
3
+ module Policy
4
+ module Predicates
5
+ # Promotion predicate: the role driving the promotion must have
6
+ # role_kind == :accept_authority in the active manifest.
7
+ #
8
+ # Accept/Reject already gate on this kind before reaching the
9
+ # promotion policy, so in the default control-flow this predicate
10
+ # trivially passes. It is kept so manifests can express the
11
+ # requirement explicitly in `rules[].promotion.requires`.
12
+ class AcceptAuthoritySigned
13
+ attr_reader :reason
14
+
15
+ def name
16
+ "accept_authority_signed"
17
+ end
18
+
19
+ def call(role:, manifest:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
20
+ role_str = role&.to_s
21
+ return true if role_str.nil? || role_str.empty?
22
+
23
+ kind = manifest.role_kind(role_str)
24
+ return true if kind == :accept_authority
25
+
26
+ @reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,19 +1,15 @@
1
+ require_relative "predicates/schema_valid"
2
+ require_relative "predicates/accept_authority_signed"
3
+
1
4
  module Textus
2
5
  module Application
3
6
  module Policy
4
- # Promotion evaluates a list of named predicates against a pending-proposal
5
- # entry and returns a Result indicating whether all requirements are met.
6
- #
7
- # Lives in Application because the predicates it wires up read live state
8
- # from explicit ports (schemas, manifest, role). The Domain-side rule
9
- # statement ("this policy requires predicates X and Y") is captured by
10
- # Textus::Domain::Policy::Promote.
11
7
  class Promotion
12
8
  Result = Struct.new(:ok?, :reasons, keyword_init: true)
13
9
 
14
10
  REGISTRY = {
15
11
  "schema_valid" => -> { Predicates::SchemaValid.new },
16
- "human_accept" => -> { Predicates::HumanAccept.new },
12
+ "accept_authority_signed" => -> { Predicates::AcceptAuthoritySigned.new },
17
13
  }.freeze
18
14
 
19
15
  def self.from_names(names)
@@ -49,10 +45,9 @@ module Textus
49
45
 
50
46
  def invoke(pred, entry:, schemas:, manifest:, role:)
51
47
  case pred.name
52
- when "human_accept"
53
- pred.call(role: role, entry: entry)
48
+ when "accept_authority_signed"
49
+ pred.call(role: role, manifest: manifest, entry: entry)
54
50
  else
55
- # Default shape: schema-style predicates that need entry + schemas + manifest.
56
51
  pred.call(entry: entry, schemas: schemas, manifest: manifest)
57
52
  end
58
53
  end
@@ -55,9 +55,11 @@ module Textus
55
55
  last_writer = @audit_log.last_writer_for(key)
56
56
  return if last_writer.nil?
57
57
 
58
+ last_writer_is_authority = @manifest.role_kind(last_writer) == :accept_authority
59
+
58
60
  env.meta.each_key do |field|
59
61
  owner = schema.maintained_by(field)
60
- next if owner.nil? || last_writer == owner || last_writer == "human"
62
+ next if owner.nil? || last_writer == owner || last_writer_is_authority
61
63
 
62
64
  violations << { "key" => key, "code" => "role_authority",
63
65
  "field" => field, "expected" => owner, "last_writer" => last_writer }
@@ -1,7 +1,11 @@
1
+ require_relative "authority_gate"
2
+
1
3
  module Textus
2
4
  module Application
3
5
  module Writes
4
6
  class Accept
7
+ include AuthorityGate
8
+
5
9
  def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
10
  @ctx = ctx
7
11
  @manifest = manifest
@@ -14,7 +18,7 @@ module Textus
14
18
  end
15
19
 
16
20
  def call(pending_key)
17
- raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
21
+ assert_accept_authority!("accept")
18
22
 
19
23
  env = Textus::Application::Reads::Get.new(
20
24
  ctx: @ctx, manifest: @manifest, file_store: @file_store,
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ # Shared gate for write verbs that require the caller to hold the
5
+ # manifest's accept_authority role. Provides one method, expressed
6
+ # as two early-returns rather than a ternary, so each failure mode
7
+ # reads on its own line.
8
+ module AuthorityGate
9
+ def assert_accept_authority!(verb)
10
+ return if @manifest.role_kind(@ctx.role) == :accept_authority
11
+
12
+ authority = @manifest.roles_with_kind(:accept_authority).first
13
+ if authority.nil?
14
+ raise ProposalError.new(
15
+ "no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
16
+ )
17
+ end
18
+
19
+ raise ProposalError.new(
20
+ "only #{authority} role can #{verb} proposals; got '#{@ctx.role}'",
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -58,7 +58,7 @@ module Textus
58
58
  ).run(mentry)
59
59
 
60
60
  publish_derived_copies(mentry, target_path, repo_root)
61
- fire_build_completed(mentry, target_path)
61
+ fire_build_completed(mentry)
62
62
 
63
63
  { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
64
64
  end
@@ -76,7 +76,7 @@ module Textus
76
76
  end
77
77
  end
78
78
 
79
- def fire_build_completed(mentry, target_path) # rubocop:disable Lint/UnusedMethodArgument
79
+ def fire_build_completed(mentry)
80
80
  envelope = reader.call(mentry.key)
81
81
  src = mentry.source
82
82
  selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
@@ -1,7 +1,11 @@
1
+ require_relative "authority_gate"
2
+
1
3
  module Textus
2
4
  module Application
3
5
  module Writes
4
6
  class Reject
7
+ include AuthorityGate
8
+
5
9
  def initialize(ctx:, manifest:, file_store:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
6
10
  @ctx = ctx
7
11
  @manifest = manifest
@@ -13,7 +17,7 @@ module Textus
13
17
  end
14
18
 
15
19
  def call(pending_key)
16
- raise ProposalError.new("only human role can reject proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
20
+ assert_accept_authority!("reject")
17
21
 
18
22
  mentry = @manifest.resolver.resolve(pending_key).entry
19
23
  unless mentry.in_proposal_zone?
@@ -8,7 +8,8 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  Textus::Infra::BuildLock.with(root: store.root) do
11
- ops = Textus::Operations.for(store, role: "builder")
11
+ role = store.manifest.roles_with_kind(:generator).first || "builder"
12
+ ops = Textus::Operations.for(store, role: role)
12
13
  result = ops.publish(prefix: prefix)
13
14
  emit(result)
14
15
  end
@@ -44,15 +44,14 @@ module Textus
44
44
  end
45
45
 
46
46
  def issue(abs_path, stem)
47
- proposed = Textus::Application::Tools::MigrateKeys.normalize(stem)
48
47
  {
49
48
  "code" => "key.illegal",
50
49
  "level" => "error",
51
50
  "subject" => abs_path,
52
51
  "path" => abs_path,
53
- "proposed_key" => proposed,
54
52
  "message" => "illegal key segment '#{stem}' at #{abs_path}",
55
- "fix" => "run 'textus key normalize --dry-run' then '--write' to rename to '#{proposed}'",
53
+ "fix" => "rename the file/directory so each segment matches [a-z0-9][a-z0-9-]* " \
54
+ "(lowercase, digits, hyphens)",
56
55
  }
57
56
  end
58
57
 
@@ -2,14 +2,16 @@ module Textus
2
2
  module Domain
3
3
  module Policy
4
4
  class Promote
5
- KNOWN = %i[schema_valid human_accept].freeze
5
+ KNOWN = %i[schema_valid accept_authority_signed].freeze
6
6
  attr_reader :requires
7
7
 
8
8
  def initialize(requires:)
9
9
  syms = Array(requires).map { |r| r.to_s.to_sym }
10
10
  unknown = syms - KNOWN
11
11
  unless unknown.empty?
12
- raise Textus::UsageError.new("unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})")
12
+ raise Textus::UsageError.new(
13
+ "unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})",
14
+ )
13
15
  end
14
16
 
15
17
  @requires = syms
@@ -2,6 +2,8 @@ module Textus
2
2
  module Domain
3
3
  module Policy
4
4
  class Refresh
5
+ ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
6
+
5
7
  attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
6
8
 
7
9
  def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
data/lib/textus/intro.rb CHANGED
@@ -18,19 +18,37 @@ module Textus
18
18
  "output" => "build-computed outputs; never hand-edited",
19
19
  }.freeze
20
20
 
21
- WRITE_FLOWS = {
22
- "human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
23
- "agent" => "propose changes by writing 'review.*' entries with --as=agent and a 'proposal:' frontmatter block; " \
24
- "a human runs 'textus accept' to apply",
25
- "runner" => "refresh intake entries with 'textus refresh KEY --as=runner' (uses the entry's declared action)",
26
- "builder" => "'textus build' computes output entries from projections; output files are never hand-edited",
21
+ # Per-kind write-flow templates. Each lambda receives the user-facing role
22
+ # name and returns a guidance string for that role. Roles whose kind has
23
+ # no template (e.g. unknown future kinds) are omitted from write_flows.
24
+ WRITE_FLOW_TEMPLATES = {
25
+ accept_authority: lambda do |name, _manifest|
26
+ "edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
27
+ end,
28
+ proposer: lambda do |name, manifest|
29
+ authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
30
+ "propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
31
+ "the #{authority} role runs 'textus accept' to apply"
32
+ end,
33
+ runner: lambda do |name, _manifest|
34
+ "refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
35
+ end,
36
+ generator: lambda do |_name, _manifest|
37
+ "'textus build' computes output entries from projections; output files are never hand-edited"
38
+ end,
27
39
  }.freeze
28
40
 
29
- # Static, store-independent guide to the agent-facing protocol. Surfaced
30
- # under the new top-level `agent_protocol` key in Intro.run. Recipes
31
- # describe CLI verbs (not Ruby Operations) because the audience is an
32
- # agent driving textus from the command line.
33
- AGENT_PROTOCOL = {
41
+ def self.write_flows_for(manifest)
42
+ manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
43
+ tmpl = WRITE_FLOW_TEMPLATES[kind]
44
+ acc[name] = tmpl.call(name, manifest) if tmpl
45
+ end
46
+ end
47
+
48
+ # Static, store-independent parts of the agent-facing protocol. The
49
+ # `role_resolution` block is derived per-manifest in agent_protocol(...)
50
+ # because role names are user-configurable.
51
+ AGENT_PROTOCOL_TEMPLATE = {
34
52
  "envelope_shape" => {
35
53
  "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
36
54
  "fields" => {
@@ -41,11 +59,6 @@ module Textus
41
59
  },
42
60
  "ref" => "SPEC.md §8",
43
61
  },
44
- "role_resolution" => {
45
- "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
46
- "roles" => %w[human agent runner builder],
47
- "ref" => "SPEC.md §5",
48
- },
49
62
  "recipes" => {
50
63
  "read" => {
51
64
  "purpose" => "find and read an entry",
@@ -92,7 +105,7 @@ module Textus
92
105
  { "name" => "schema", "summary" => "field shape for a key family" },
93
106
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
94
107
  { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
95
- { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key normalize'" },
108
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
96
109
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
97
110
  { "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
98
111
  { "name" => "refresh", "summary" => "run an action for an intake entry" },
@@ -105,6 +118,17 @@ module Textus
105
118
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
106
119
  ].freeze
107
120
 
121
+ def self.agent_protocol(manifest)
122
+ AGENT_PROTOCOL_TEMPLATE.merge(
123
+ "role_resolution" => {
124
+ "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
125
+ "default 'human'",
126
+ "roles" => manifest.role_mapping.keys,
127
+ "ref" => "SPEC.md §5",
128
+ },
129
+ )
130
+ end
131
+
108
132
  def self.run(store)
109
133
  {
110
134
  "protocol" => PROTOCOL_ID,
@@ -112,9 +136,9 @@ module Textus
112
136
  "zones" => zones_for(store),
113
137
  "entries" => entries_for(store),
114
138
  "hooks" => hooks_for(store),
115
- "write_flows" => WRITE_FLOWS.dup,
139
+ "write_flows" => write_flows_for(store.manifest),
116
140
  "cli_verbs" => CLI_VERBS.map(&:dup),
117
- "agent_protocol" => AGENT_PROTOCOL,
141
+ "agent_protocol" => agent_protocol(store.manifest),
118
142
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
119
143
  }
120
144
  end
@@ -130,7 +154,7 @@ module Textus
130
154
 
131
155
  def self.entries_for(store)
132
156
  store.manifest.entries.map do |e|
133
- derived = store.manifest.zone_writers(e.zone).include?("builder")
157
+ derived = store.manifest.zone_kinds(e.zone).include?(:generator)
134
158
  {
135
159
  "key" => e.key,
136
160
  "zone" => e.zone,
@@ -25,8 +25,8 @@ module Textus
25
25
  raise UsageError.new("entry '#{@key}': #{e.message}")
26
26
  end
27
27
 
28
- def in_generator_zone? = zone_writers.include?("builder")
29
- def in_proposal_zone? = zone_writers.include?("agent")
28
+ def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
29
+ def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
30
30
 
31
31
  def nested? = false
32
32
  def derived? = false
@@ -64,13 +64,7 @@ module Textus
64
64
 
65
65
  def self.parse_source(raw, key)
66
66
  compute = raw["compute"]
67
- if compute.nil?
68
- # Tolerate legacy derived entries with bare template (no compute block):
69
- # treat as projection with no select.
70
- return Derived::Projection.new(select: nil, pluck: nil, sort_by: nil, transform: nil) if raw["template"]
71
-
72
- raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:")
73
- end
67
+ raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:") if compute.nil?
74
68
 
75
69
  unless COMPUTE_KINDS.include?(compute["kind"])
76
70
  raise BadManifest.new(
@@ -1,6 +1,8 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Resolver
4
+ Resolution = Data.define(:entry, :path, :remaining)
5
+
4
6
  def initialize(manifest)
5
7
  @manifest = manifest
6
8
  end
@@ -83,7 +85,8 @@ module Textus
83
85
 
84
86
  illegal = segs.find { |s| !valid_segment?(s) }
85
87
  if illegal
86
- warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key normalize --dry-run'")
88
+ warn("textus: skipping illegal key segment '#{illegal}' at #{path} — " \
89
+ "rename to match [a-z0-9][a-z0-9-]* (run 'textus doctor' for the full list)")
87
90
  return nil
88
91
  end
89
92
 
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class Manifest
3
+ module RoleKinds
4
+ DEFAULT_MAPPING = {
5
+ "human" => :accept_authority,
6
+ "agent" => :proposer,
7
+ "builder" => :generator,
8
+ "runner" => :runner,
9
+ }.freeze
10
+
11
+ # Returns { role_name => kind_symbol }. When `roles:` is declared we use
12
+ # exactly that; defaults are *not* layered in (declaring roles is an opt-in
13
+ # to a fully user-defined vocabulary).
14
+ def self.resolve(raw_roles)
15
+ return DEFAULT_MAPPING if raw_roles.nil?
16
+
17
+ raw_roles.to_h { |r| [r["name"], r["kind"].to_sym] }.freeze
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,7 +1,9 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  module Schema
4
- ROOT_KEYS = %w[version zones entries rules].freeze
4
+ ROOT_KEYS = %w[version roles zones entries rules].freeze
5
+ ROLE_KEYS = %w[name kind].freeze
6
+ ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
5
7
  ZONE_KEYS = %w[name write_policy read_policy].freeze
6
8
  ENTRY_KEYS = %w[
7
9
  key path zone kind schema owner nested format
@@ -19,6 +21,7 @@ module Textus
19
21
  raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
20
22
 
21
23
  walk(raw, ROOT_KEYS, "$")
24
+ validate_roles!(raw["roles"])
22
25
  Array(raw["zones"]).each_with_index do |z, i|
23
26
  walk(z, ZONE_KEYS, "$.zones[#{i}]")
24
27
  end
@@ -37,6 +40,47 @@ module Textus
37
40
  end
38
41
  walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
39
42
  end
43
+ validate_zone_writers_declared!(raw)
44
+ end
45
+
46
+ def self.validate_zone_writers_declared!(raw)
47
+ return if raw["roles"].nil? # default mapping is permissive
48
+
49
+ declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
50
+ Array(raw["zones"]).each do |z|
51
+ Array(z["write_policy"]).each_with_index do |w, j|
52
+ next if declared.include?(w)
53
+
54
+ raise BadManifest.new(
55
+ "zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
56
+ "(declared roles: #{declared.to_a.join(", ")})",
57
+ )
58
+ end
59
+ end
60
+ end
61
+
62
+ def self.validate_roles!(roles)
63
+ return if roles.nil?
64
+ raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
65
+
66
+ accept_authority_count = 0
67
+ roles.each_with_index do |r, i|
68
+ path = "$.roles[#{i}]"
69
+ walk(r, ROLE_KEYS, path)
70
+ name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
71
+ kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
72
+ unless ROLE_KINDS.include?(kind)
73
+ raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
74
+ end
75
+
76
+ accept_authority_count += 1 if kind == "accept_authority"
77
+ end
78
+ return unless accept_authority_count > 1
79
+
80
+ raise BadManifest.new(
81
+ "manifest declares #{accept_authority_count} accept_authority roles; " \
82
+ "at most one accept_authority role is allowed",
83
+ )
40
84
  end
41
85
 
42
86
  def self.validate_fetch_timeout!(value, path)
@@ -1,7 +1,7 @@
1
1
  require "yaml"
2
2
  require_relative "manifest/schema"
3
- require_relative "manifest/resolution"
4
3
  require_relative "manifest/resolver"
4
+ require_relative "manifest/role_kinds"
5
5
 
6
6
  module Textus
7
7
  class Manifest
@@ -30,6 +30,26 @@ module Textus
30
30
  )
31
31
  end
32
32
 
33
+ def role_mapping
34
+ @role_mapping ||= RoleKinds.resolve(@raw["roles"])
35
+ end
36
+
37
+ def role_kind(name)
38
+ role_mapping[name]
39
+ end
40
+
41
+ def roles_with_kind(kind)
42
+ role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
43
+ end
44
+
45
+ def zone_kinds(zone_name)
46
+ @zone_kinds_cache ||= {}
47
+ @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
48
+ k = role_kind(w)
49
+ acc << k if k
50
+ end.freeze
51
+ end
52
+
33
53
  def self.parse(yaml_text, root: ".")
34
54
  raw = YAML.safe_load(yaml_text, aliases: false)
35
55
  check_version!(raw, "<string>")
@@ -49,7 +49,14 @@ module Textus
49
49
  end
50
50
  raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
51
51
 
52
- ops = Textus::Operations.for(store, role: "human")
52
+ authority = store.manifest.roles_with_kind(:accept_authority).first
53
+ if authority.nil?
54
+ raise UsageError.new(
55
+ "schema migrate requires a role with kind :accept_authority in the manifest; " \
56
+ "none declared (add e.g. `- { name: owner, kind: accept_authority }` to roles:)",
57
+ )
58
+ end
59
+ ops = Textus::Operations.for(store, role: authority)
53
60
  touched = []
54
61
  store.manifest.resolver.enumerate.each do |row|
55
62
  env = ops.get(row[:key])
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.20.0"
2
+ VERSION = "0.20.2"
3
3
  PROTOCOL = "textus/3"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.20.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -110,7 +110,7 @@ files:
110
110
  - exe/textus
111
111
  - lib/textus.rb
112
112
  - lib/textus/application/context.rb
113
- - lib/textus/application/policy/predicates/human_accept.rb
113
+ - lib/textus/application/policy/predicates/accept_authority_signed.rb
114
114
  - lib/textus/application/policy/predicates/schema_valid.rb
115
115
  - lib/textus/application/policy/promotion.rb
116
116
  - lib/textus/application/projection.rb
@@ -133,9 +133,8 @@ files:
133
133
  - lib/textus/application/refresh/all.rb
134
134
  - lib/textus/application/refresh/orchestrator.rb
135
135
  - lib/textus/application/refresh/worker.rb
136
- - lib/textus/application/tools/migrate_keys.rb
137
- - lib/textus/application/tools/migrate_manifest_to_kinds.rb
138
136
  - lib/textus/application/writes/accept.rb
137
+ - lib/textus/application/writes/authority_gate.rb
139
138
  - lib/textus/application/writes/delete.rb
140
139
  - lib/textus/application/writes/envelope_io.rb
141
140
  - lib/textus/application/writes/materializer.rb
@@ -170,7 +169,6 @@ files:
170
169
  - lib/textus/cli/verb/hooks.rb
171
170
  - lib/textus/cli/verb/init.rb
172
171
  - lib/textus/cli/verb/intro.rb
173
- - lib/textus/cli/verb/key_normalize.rb
174
172
  - lib/textus/cli/verb/list.rb
175
173
  - lib/textus/cli/verb/mv.rb
176
174
  - lib/textus/cli/verb/published.rb
@@ -212,7 +210,6 @@ files:
212
210
  - lib/textus/domain/freshness/verdict.rb
213
211
  - lib/textus/domain/outcome.rb
214
212
  - lib/textus/domain/permission.rb
215
- - lib/textus/domain/policy.rb
216
213
  - lib/textus/domain/policy/handler_allowlist.rb
217
214
  - lib/textus/domain/policy/matcher.rb
218
215
  - lib/textus/domain/policy/promote.rb
@@ -263,8 +260,8 @@ files:
263
260
  - lib/textus/manifest/entry/validators/index_filename.rb
264
261
  - lib/textus/manifest/entry/validators/inject_intro.rb
265
262
  - lib/textus/manifest/entry/validators/publish_each.rb
266
- - lib/textus/manifest/resolution.rb
267
263
  - lib/textus/manifest/resolver.rb
264
+ - lib/textus/manifest/role_kinds.rb
268
265
  - lib/textus/manifest/rules.rb
269
266
  - lib/textus/manifest/schema.rb
270
267
  - lib/textus/mustache.rb
@@ -1,30 +0,0 @@
1
- module Textus
2
- module Application
3
- module Policy
4
- module Predicates
5
- class HumanAccept
6
- attr_reader :reason
7
-
8
- def name
9
- "human_accept"
10
- end
11
-
12
- # The role is passed explicitly. In practice, Accept already enforces
13
- # role == "human" before reaching the promotion gate, so this predicate
14
- # trivially passes. It documents intent and future-proofs multi-actor
15
- # accept flows.
16
- def call(role:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
17
- role_str = role&.to_s
18
- # If we cannot determine the role, trust that Accept has already
19
- # checked — allow through.
20
- return true if role_str.nil? || role_str.empty?
21
-
22
- ok = (role_str == "human")
23
- @reason = "current role is '#{role_str}', expected 'human'" unless ok
24
- ok
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,191 +0,0 @@
1
- module Textus
2
- module Application
3
- module Tools
4
- # Run-once helper that renames files/directories whose basenames don't
5
- # conform to the strict key grammar (§3 of plan-1.2). Only walks
6
- # nested: true manifest entries — leaf entries with illegal declared
7
- # keys are caught by Manifest load and must be fixed by hand.
8
- module MigrateKeys
9
- SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
10
-
11
- module_function
12
-
13
- # Returns the envelope hash described in plan-1.2 §3.
14
- def run(store, write: false)
15
- plan = build_plan(store)
16
- collisions = plan[:collisions]
17
- renames = plan[:renames]
18
-
19
- ok = collisions.empty?
20
- apply!(store, renames) if write && ok
21
-
22
- {
23
- "protocol" => Textus::PROTOCOL,
24
- "mode" => write ? "write" : "dry-run",
25
- "renames" => renames.map { |r| envelope_rename(r) },
26
- "collisions" => collisions.map { |c| envelope_collision(c) },
27
- "ok" => ok,
28
- }
29
- end
30
-
31
- # ------------------------------------------------------------------
32
- # Plan construction
33
- # ------------------------------------------------------------------
34
-
35
- # Returns { renames: [...], collisions: [...] }
36
- # Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
37
- # Each collision: { target:, sources: [...] }
38
- def build_plan(store) # rubocop:disable Metrics/AbcSize
39
- renames = []
40
- target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
41
-
42
- store.manifest.entries.each do |entry|
43
- next unless entry.nested?
44
-
45
- base = File.join(store.root, "zones", entry.path)
46
- next unless File.directory?(base)
47
-
48
- # Walk depth-first. Order matters when computing the "new key"
49
- # for files inside a renamed directory: we record renames bottom-up,
50
- # so children are renamed before their parents on apply.
51
- walk(base) do |abs_path, is_dir|
52
- next if abs_path == base
53
-
54
- basename = File.basename(abs_path)
55
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
56
- next if stem.match?(SEGMENT)
57
-
58
- new_stem = normalize(stem)
59
- # Skip if normalization yields the same stem (e.g. already-legal
60
- # under a different lens). In practice match?(SEGMENT) catches that
61
- # above; this is a safety net.
62
- next if new_stem == stem
63
-
64
- new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
65
- target = File.join(File.dirname(abs_path), new_basename)
66
- target_buckets[target] << abs_path
67
-
68
- renames << {
69
- from: abs_path,
70
- to: target,
71
- kind: is_dir ? :dir : :file,
72
- entry: entry,
73
- base: base,
74
- }
75
- end
76
- end
77
-
78
- collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
79
- .map { |t, srcs| { target: t, sources: srcs.sort } }
80
-
81
- # Drop colliding entries from renames (we won't apply any of them)
82
- colliding_targets = collisions.to_set { |c| c[:target] }
83
- renames.reject! { |r| colliding_targets.include?(r[:to]) }
84
-
85
- # Sort renames bottom-up (deepest path first) so children move before parents.
86
- renames.sort_by! { |r| -r[:from].count("/") }
87
-
88
- { renames: renames, collisions: collisions }
89
- end
90
-
91
- # Yields [absolute_path, is_dir] for every entry under root. Depth-first.
92
- def walk(root, &block)
93
- Dir.each_child(root) do |name|
94
- abs = File.join(root, name)
95
- if File.directory?(abs)
96
- walk(abs, &block)
97
- yield abs, true
98
- else
99
- yield abs, false
100
- end
101
- end
102
- end
103
-
104
- # Deterministic transform per plan §3.
105
- def normalize(s)
106
- s = s.downcase
107
- s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
108
- s = s.gsub(/-+/, "-")
109
- s.sub(/\A-+/, "").sub(/-+\z/, "")
110
- end
111
-
112
- # ------------------------------------------------------------------
113
- # Apply
114
- # ------------------------------------------------------------------
115
-
116
- def apply!(store, renames)
117
- audit = Textus::Infra::AuditLog.new(store.root)
118
- renames.each do |r|
119
- # Bottom-up order means a child's ancestors haven't moved yet, so
120
- # `from`/`to` are valid as-recorded. The audit `key` reflects the
121
- # eventual full key once every rename in this batch has applied.
122
- from = r[:from]
123
- to = r[:to]
124
- File.rename(from, to)
125
- new_key = compute_new_key(r, renames)
126
- audit.append(
127
- role: "runner",
128
- verb: "migrate-keys",
129
- key: new_key,
130
- etag_before: nil,
131
- etag_after: nil,
132
- extras: { "from" => from, "to" => to },
133
- )
134
- end
135
- end
136
-
137
- # If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
138
- def resolve_current_path(path, renames)
139
- out = path
140
- renames.each do |r|
141
- prefix = r[:from] + "/"
142
- out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
143
- end
144
- out
145
- end
146
-
147
- # New full key after applying all renames up through this one.
148
- def compute_new_key(rename, renames)
149
- base = rename[:base]
150
- entry = rename[:entry]
151
- new_to = resolve_current_path(rename[:to], renames)
152
-
153
- rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
154
- stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
155
- stripped ||= rel
156
- segs = stripped.split("/").reject(&:empty?)
157
- (entry.key.split(".") + segs).join(".")
158
- end
159
-
160
- # ------------------------------------------------------------------
161
- # Envelope helpers
162
- # ------------------------------------------------------------------
163
-
164
- def envelope_rename(r)
165
- {
166
- "from" => r[:from],
167
- "to" => r[:to],
168
- "old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
169
- "new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
170
- }
171
- end
172
-
173
- def envelope_collision(col)
174
- { "target" => col[:target], "sources" => col[:sources] }
175
- end
176
-
177
- def path_to_key(path, base, entry, kind)
178
- rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
179
- stripped =
180
- if kind == :dir
181
- rel
182
- else
183
- rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
184
- end
185
- segs = stripped.split("/").reject(&:empty?)
186
- (entry.key.split(".") + segs).join(".")
187
- end
188
- end
189
- end
190
- end
191
- end
@@ -1,31 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Application
5
- module Tools
6
- module MigrateManifestToKinds
7
- module_function
8
-
9
- def upgrade_yaml(yaml_text)
10
- raw = YAML.safe_load(yaml_text, aliases: false)
11
- raw["entries"] = Array(raw["entries"]).map { |row| upgrade_row(row) }
12
- YAML.dump(raw)
13
- end
14
-
15
- def upgrade_row(row)
16
- return row if row["kind"]
17
-
18
- row.merge("kind" => infer_kind(row))
19
- end
20
-
21
- def infer_kind(row)
22
- return "intake" if row["intake"].is_a?(Hash) || row["intake_handler"]
23
- return "derived" if row["template"] || row["compute"] || row["generator"] || row["projection"]
24
- return "nested" if row["nested"] == true
25
-
26
- "leaf"
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,48 +0,0 @@
1
- module Textus
2
- class CLI
3
- class Verb
4
- class KeyNormalize < Verb
5
- command_name "normalize"
6
- parent_group Group::Key
7
-
8
- option :write, "--write"
9
- option :dry_run, "--dry-run"
10
- option :upgrade_manifest, "--upgrade-manifest"
11
-
12
- def call(store)
13
- if upgrade_manifest
14
- run_upgrade_manifest(store)
15
- else
16
- effective_write = write && !dry_run
17
- res = Textus::Application::Tools::MigrateKeys.run(store, write: effective_write || false)
18
- emit(res, exit_code: res["ok"] ? 0 : 1)
19
- end
20
- end
21
-
22
- private
23
-
24
- def run_upgrade_manifest(store)
25
- manifest_path = File.join(store.root, "manifest.yaml")
26
- orig = File.read(manifest_path)
27
- new_yaml = Textus::Application::Tools::MigrateManifestToKinds.upgrade_yaml(orig)
28
-
29
- if dry_run
30
- diff_lines = unified_diff(orig, new_yaml, manifest_path)
31
- emit({ "protocol" => PROTOCOL, "dry_run" => true, "diff" => diff_lines, "ok" => true }, exit_code: 0)
32
- else
33
- File.write(manifest_path, new_yaml)
34
- puts "upgraded manifest at #{manifest_path}"
35
- emit({ "protocol" => PROTOCOL, "upgraded" => manifest_path, "ok" => true }, exit_code: 0)
36
- end
37
- end
38
-
39
- def unified_diff(before, after, _path)
40
- before.lines.zip(after.lines).each_with_object([]) do |(a, b), acc|
41
- acc << "- #{a.chomp}" if a && a != b
42
- acc << "+ #{b.chomp}" if b && a != b
43
- end
44
- end
45
- end
46
- end
47
- end
48
- end
@@ -1,7 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
5
- end
6
- end
7
- end
@@ -1,5 +0,0 @@
1
- module Textus
2
- class Manifest
3
- Resolution = Data.define(:entry, :path, :remaining)
4
- end
5
- end