@vectros-ai/cli 0.19.0 → 0.21.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.
package/AUTHORING.md CHANGED
@@ -54,7 +54,11 @@ A name must be 2–50 chars: a lowercase letter, then lowercase letters / digits
54
54
  `namespaces` and the `*` wildcard are **hard-rejected, no override**.
55
55
  `profiles` is a narrower exception — `r`/`u`/`d` are admitted, and `c` only
56
56
  when qualified `profiles:c:self` (create a profile for yourself; a bare
57
- `profiles:c`, or one naming another principal, still hard-rejects). Any
57
+ `profiles:c`, or one naming another principal, still hard-rejects).
58
+ `scripts` is the other — `scripts:x:<script-name>` (execute one named
59
+ script) is admitted, and nothing else on that resource is: a bare
60
+ `scripts:x`, a wildcard `scripts:x:*`, and `scripts:c` (authoring) all
61
+ hard-reject. Any
58
62
  requested `capabilities` are checked the same way, against their own,
59
63
  separate allowlist — see "Capabilities" below. A forked or hand-written
60
64
  blueprint gets the *identical* gates a bundled one does — it can never
@@ -69,6 +73,16 @@ A name must be 2–50 chars: a lowercase letter, then lowercase letters / digits
69
73
  `dataScope: {}` when a shared/tenant-wide resource is what you intend
70
74
  (warnings — they print but don't fail).
71
75
 
76
+ A schema may also declare `expectedScopeDims` — the ownership dimensions
77
+ its records are typically scoped by, e.g. `expectedScopeDims: [org,
78
+ client]`. Purely advisory (never sent to the platform, never runtime-
79
+ enforced): it lets the lint warn when a role/`accessProfile` clause grants
80
+ `r`/`u`/`d` on that typeName (via a type-qualified action like
81
+ `records:r:case`) but its `dataScope` names only SOME of the declared
82
+ dims — reads never require full-dimension coverage the way creates do, so
83
+ a clause naming one of two declared dims silently leaves the other
84
+ unconstrained. Omit the field entirely and this lint never fires.
85
+
72
86
  `validate` accepts a file path **or** a bundled blueprint name.
73
87
 
74
88
  ### 3. `plan` — preview before you mint
@@ -196,8 +210,9 @@ accessProfile:
196
210
  # capabilities: [member-lifecycle]
197
211
  # A SEPARATE gated dimension from allowedActions — named platform effects a
198
212
  # resource:ops verb can't express (e.g. creating/removing an identity in
199
- # your tenant). Only 'member-lifecycle' is admitted today; every other name
200
- # is hard-rejected, same posture as the scope gate. See "Capabilities" below.
213
+ # your tenant). 'member-lifecycle' and 'delegate-principal-stamp' are
214
+ # admitted today; every other name is hard-rejected, same posture as the
215
+ # scope gate. See "Capabilities" below.
201
216
  #
202
217
  # Instead of allowedActions (+ optional dataScope/capabilities), accessProfile
203
218
  # may declare `roleIds: [<roleId>, ...]` — one or more roles declared below,
@@ -287,7 +302,7 @@ namespaces:
287
302
  # Registered in the SAME bootstrap-token phase as issuers, before schemas.
288
303
  # Owned by this blueprint's own contextId (above) by default. `tenantWide: true` opts
289
304
  # out of that, but declaring it is not enough on its own: the applying credential must
290
- # separately hold OWNER-only authority, and `bootstrap`/`apply` refuse to even request
305
+ # separately hold OWNER-only authority, and `vectros bootstrap` refuses to even request
291
306
  # it without the explicit `--allow-tenant-wide-namespaces` flag — a blueprint cannot make
292
307
  # this happen by itself. Registration is create-once, NOT idempotent server-side: a
293
308
  # re-apply matching every declared field converges silently, but one that disagrees
@@ -438,6 +453,90 @@ so if you seed a referencing record, seed its target first. To query "which X
438
453
  reference Y", add the reference field to `lookupFields` as an equality lookup
439
454
  (there is no reverse-reference index on this surface).
440
455
 
456
+ ### Scripts and triggers — code a record write runs
457
+
458
+ A blueprint can ship the code an event runs, and the rule that runs it. Two
459
+ top-level blocks, applied in-context and in this order (`scripts` first —
460
+ a rule's `scriptRef` is checked when the rule is *declared*, not when it fires,
461
+ so a rule naming a script with no versions is refused outright):
462
+
463
+ ```yaml
464
+ schemas:
465
+ - typeName: intake
466
+ displayName: Intake
467
+ # A schema fires triggers only when it opts in. Default is false.
468
+ capabilities: { triggersEnabled: true }
469
+ fields:
470
+ - { fieldId: status, fieldType: string, inline: true } # `inline` = projectable
471
+
472
+ scripts:
473
+ notify-assignee:
474
+ source: |
475
+ export default function (input) {
476
+ return { notified: input.recordId };
477
+ }
478
+
479
+ triggers:
480
+ on-intake-update:
481
+ firingSource: { schemaName: intake, event: UPDATE }
482
+ # A script this blueprint ships is always `latest` — a pin is rejected at authoring time.
483
+ scriptRef: { name: notify-assignee, version: latest }
484
+ fields: [status]
485
+ allowedActions: [records:r:intake, records:u:intake]
486
+ dataScope: { "scope:org": ["${{ input.scope.org }}"] }
487
+ manifest: [records.get, records.update]
488
+ ```
489
+
490
+ Four things to get right, because each is a refusal rather than a warning:
491
+
492
+ - **`fields` is required.** It names the record fields projected into the
493
+ script's `input.record` (and, on an `UPDATE`, `input.previous`). `fields: []`
494
+ is the explicit "project nothing" declaration — required rather than defaulted
495
+ so that projecting nothing is something you state. An entry may name only a
496
+ field the schema keeps **inline** (`inline: true`, `filterable: true`, or a
497
+ lookup field) and never one marked `sensitive`. `validate` checks this against
498
+ the schema in the same blueprint, so you see it before an apply starts.
499
+ - **A rule that declares any `fields` must be able to read what it fires on** —
500
+ its grant needs a `records:r` clause covering the firing schema's type, and
501
+ that clause may not carry a *literal* `dataScope` constraint. A
502
+ `${{ input.* }}` placeholder is fine, because it resolves to the firing
503
+ record's own value.
504
+ - **The schema must opt in** with `capabilities.triggersEnabled: true`, or the
505
+ rule is refused at `plan` time rather than created and silently never fired.
506
+ - **The grant is live authority.** A trigger's `allowedActions`/`dataScope`, or
507
+ the roles its `roleIds` composes, are what the script actually executes under
508
+ — author them as narrowly as any other credential's. `${{ self.* }}`,
509
+ `${{ under.self.* }}` and `${{ member.* }}` are rejected inside a trigger's
510
+ grant: they resolve against the trigger principal's identity, not yours.
511
+
512
+ **Shipping a script does not make it runnable.** A `scripts:` entry provisions
513
+ the source; the two consumers reach it differently. A **trigger** names it in
514
+ `scriptRef` and runs it under the trigger's own grant — nothing further needed.
515
+ **Your app calling `POST /v1/scripts/execute` needs an explicit grant**, and the
516
+ role that calls it must carry `scripts:x:<script-name>`, qualified to that one
517
+ name:
518
+
519
+ ```yaml
520
+ roles:
521
+ case-handler:
522
+ - allowedActions: [scripts:x:create-case]
523
+ dataScope:
524
+ scope:org: ['${{ member.scope.org }}']
525
+ ```
526
+
527
+ Omit it and the call is a 403 that cannot tell you the grant is what's missing.
528
+ See *The scope gate* for why the bare and wildcard forms are refused.
529
+
530
+ **Triggers are the one block an apply reconciles by absence.** Remove an entry
531
+ and re-apply, and its rule and access profile are deleted, so a trigger you
532
+ removed cannot keep acting. Its service principal is kept, so re-adding the same
533
+ trigger reattaches to one identity. Removing a `scripts:` entry deletes nothing
534
+ — see *The idempotency contract*, below, for why.
535
+
536
+ Full field grammar, including the byte caps on `source` and
537
+ `declaredInputContract` and the `manifest` verb vocabulary:
538
+ [`@vectros-ai/blueprints`](https://www.npmjs.com/package/@vectros-ai/blueprints).
539
+
441
540
  ### The scope gate — what you can and can't request
442
541
 
443
542
  The gate allows only **data-plane** resources:
@@ -446,20 +545,44 @@ The gate allows only **data-plane** resources:
446
545
  registered — ownership-gated the same way as `records`; it grants no
447
546
  authority over the namespace *registry* itself, which stays root-key-only).
448
547
  Operations are `c|r|u|d`, combinable (`records:cru`), with a per-resource
449
- wildcard (`records:*`). Anything else — control-plane resources or the bare
548
+ wildcard (`records:*`). Anything else — control-plane resources and the bare
450
549
  `*` — is rejected, and there is **intentionally no override flag**. This is
451
550
  *why* it's safe to apply a blueprint you didn't write: the binary is the trust
452
551
  boundary, not the file.
453
552
 
553
+ **One exception on `scripts`, because a blueprint that ships a script has to be
554
+ able to grant permission to run it.** `scripts:x:<script-name>` is admitted.
555
+ Everything else on that resource is not:
556
+
557
+ | Grant | Verdict |
558
+ |---|---|
559
+ | `scripts:x:create-case` | **admitted** — execute that one script |
560
+ | `scripts:x` (bare) | rejected — it would mean "run every script in this context, including ones added later that your reviewer never saw" |
561
+ | `scripts:x:*` | rejected — **there is no wildcard form.** The qualifier is compared to the script name exactly, so a `*` matches nothing: the grant would store cleanly and then refuse every execution, with no error able to say why |
562
+ | `scripts:c` (authoring) | rejected — creating a script is a design-time admin surface, and a blueprint ships its source rather than granting the ability to write one |
563
+ | `triggers:*` | rejected — unchanged |
564
+
565
+ Execution consults no data scope, so this widens **what can be composed**, never
566
+ **what data is reachable**: every write the script makes is still checked, one
567
+ by one, against the caller's own permissions. A credential holding only
568
+ `scripts:x:<name>` runs the script and is refused on its first write.
569
+
570
+ If your blueprint declares a `scripts:` block, `validate` also checks each
571
+ `scripts:x:<name>` grant against the names you actually ship, and refuses a
572
+ grant that names something else. That check exists here because it cannot exist
573
+ anywhere else: the platform stores a misspelled grant without complaint, and
574
+ the resulting 403 at execution time deliberately cannot tell you the name was
575
+ wrong.
576
+
454
577
  ### Capabilities (`granted_capabilities`) — a separate, equally-gated dimension
455
578
 
456
579
  An `accessProfile` or role clause may also declare `capabilities: [...]` —
457
580
  named platform effects (like creating or removing an identity in your tenant)
458
581
  that a `resource:ops` scope token can't express. This rides its own field,
459
- separate from `allowedActions`, and has its own allowlist: **only
460
- `member-lifecycle` is admitted today.** Every other capability name —
461
- including ones the platform's own registry recognizes — is refused, the same
462
- fail-closed, deliberately-narrow posture as the scope gate above. `validate`,
582
+ separate from `allowedActions`, and has its own allowlist: **`member-lifecycle`
583
+ and `delegate-principal-stamp` are admitted today.** Every other capability
584
+ name — including ones the platform's own registry recognizes — is refused,
585
+ the same fail-closed, deliberately-narrow posture as the scope gate above. `validate`,
463
586
  `plan`, `blueprint-test` and `bootstrap` all report a rejected capability the
464
587
  same way they report a rejected scope: nothing mints, and the CLI exits
465
588
  non-zero.
@@ -494,6 +617,24 @@ from a blueprint's `externalId` colliding with an unrelated identity, so it asks
494
617
  rather than guessing either way. `--rotate` is exempt only when the local
495
618
  record already names the same principal being rotated.
496
619
 
620
+ **A second qualification, and a different KIND of one: a `scripts:` entry is not
621
+ idempotent server-side.** Every push of a script name stores a genuinely new,
622
+ immutable version — there is no update, and no create-or-return. Convergence
623
+ here is the loader's doing, not the platform's: it reads the name's current
624
+ version and compares the stored `source` and `declaredInputContract` against
625
+ what the blueprint declares, and pushes only on a real difference. So a re-apply
626
+ of an unedited blueprint writes nothing, and editing `source` stores exactly one
627
+ new version, which every `version: latest` rule then picks up. The apply summary
628
+ reports which of the two happened per script.
629
+
630
+ Two consequences worth planning for. A concurrent apply of the same blueprint
631
+ can collide on one script name; the loader re-reads and retries once, so two
632
+ runners applying identical content converge rather than storing it twice, but a
633
+ sustained collision fails the apply and asks you to re-run. And **removing a
634
+ `scripts:` entry does not delete anything** — every stored version stays, because
635
+ a script carries no record of which blueprint pushed it. Remove one deliberately
636
+ with `vectros scripts delete`.
637
+
497
638
  ## Distribution
498
639
 
499
640
  - **Bundled** — reviewed, versioned blueprints shipped in the library
package/CHANGELOG.md CHANGED
@@ -3,6 +3,345 @@
3
3
  All notable changes to `@vectros-ai/cli` are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org).
5
5
 
6
+ ## 0.21.0 — 2026-09-07
7
+
8
+ ### Added
9
+
10
+ - **`vectros bootstrap --blueprint` provisions a blueprint's `scripts` block**, before the triggers
11
+ that reference them. A trigger's `scriptRef` is checked when the rule is DECLARED, not when it
12
+ fires, so the ordering is what makes a blueprint shipping both work at all — the other way round,
13
+ the trigger is refused for naming a script that does not exist yet, after the apply has already
14
+ minted that trigger's service principal and access profile.
15
+
16
+ **A re-apply pushes nothing when the source has not changed.** Pushing a script is not
17
+ idempotent — every push stores a new, server-numbered version — so an apply reads the name's
18
+ current version first and stores a new one only when `source` or `declaredInputContract` actually
19
+ differs. Without that, every apply of an unedited blueprint would mint a version and silently
20
+ re-point every `version: latest` rule at code identical to what it was already running. Editing
21
+ the source pushes exactly one new version, which `latest` rules then pick up. A concurrent push
22
+ from another apply is re-read rather than blindly retried, so two runners applying the same
23
+ blueprint converge instead of storing the same source twice.
24
+
25
+ **Removing a `scripts` entry does NOT delete the script** — the deliberate asymmetry with
26
+ triggers, which an apply does reconcile by absence. A script carries no record of which blueprint
27
+ pushed it, so an apply cannot tell one it created from one pushed by hand; the delete would be
28
+ unrecoverable, since the stored source is the only copy, and would break any trigger still
29
+ referencing it. An unreferenced script version is inert — it fires nothing and grants nothing — so
30
+ it is left in place. `blueprint plan` states this every time, including when the blueprint declares
31
+ no scripts at all, which is exactly the plan you see after removing the last one.
32
+
33
+ - **`vectros scripts <push|list|get|delete>`** — manage script objects outside a full apply, which
34
+ previously had no route at all short of raw HTTP.
35
+
36
+ - `scripts push <name> --source-file <path> --context <ctx>` stores a new version and reports its
37
+ number. It always pushes, matching the platform: `--if-changed` opts into the convergent
38
+ behaviour instead, which is what a CI job re-running the same push wants. Name grammar and both
39
+ byte caps are checked locally, so a bad push exits 2 naming the field instead of sending 300 KB
40
+ to earn a rejection. `--input-contract` / `--input-contract-file` supply the optional contract.
41
+ - `scripts list --context <ctx> [--name <name>]` — every version of one name, or all scripts. The
42
+ human view shows a byte count rather than the source, so listing a much-edited script does not
43
+ dump hundreds of kilobytes into a terminal; `--json` carries the source for a machine consumer.
44
+ - `scripts get <id> --context <ctx>` shows one version including its full source.
45
+ - `scripts delete <id> --context <ctx>` removes ONE version, leaving other versions of the name
46
+ alone — and warns that nothing checked whether a trigger still referenced it. That warning is
47
+ the point: a rule pinned to the deleted version fails on every firing, while a rule on `latest`
48
+ silently falls back to the previous version and keeps running older code.
49
+
50
+ There is no `scripts update`: a stored version is immutable and the update route always rejects.
51
+ `--context` is required on all four, since a trigger resolves its `scriptRef` inside its own
52
+ context and a defaulted one would push where nobody asked.
53
+
54
+ - **`blueprint-test` tears down the script versions a run pushed**, by id, after the trigger teardown
55
+ so a rule never briefly outlives the script it names. A version the run did not push — the source
56
+ already matched what the blueprint declares — is reported as skipped and left alone.
57
+
58
+ - **`blueprint-test` tears down trigger rules it created.** The harness deletes what a run
59
+ provisions, and triggers were the one resource it could not reach — so testing a blueprint with a
60
+ `triggers:` block left the rule behind, and because a schema will not delete while a rule fires
61
+ off it, the leak kept the schema alive and the next run failed against it. Deletion is
62
+ created-only and requires TWO independent factors, mirroring what `bootstrap` already does when it
63
+ reconciles a trigger away: the rule must be absent from the pre-run snapshot AND carry this
64
+ blueprint's own `provisionedBy` marker. A rule that pre-existed, one another blueprint provisioned,
65
+ one created directly through the API, or any rule at all when the snapshot could not be read, is
66
+ reported and left in place. Each rule's access profile goes with it — that is where its declared
67
+ authority lives — while its service principal is kept, so re-adding the same trigger reattaches to
68
+ one identity.
69
+
70
+ - **Blueprint trigger `fields` and schema field `inline` are provisioned.** A trigger rule declares
71
+ which record fields it projects into its script's `input.record` (and, on an `UPDATE` firing,
72
+ `input.previous`); `vectros bootstrap --blueprint` forwards that declaration, and forwards a
73
+ schema field's `inline` flag, which is what makes a field projectable at all. `blueprint plan`
74
+ now names the projected fields per trigger — this is the record data that leaves storage on every
75
+ firing, so an operator approving a plan should see it — and spells out a trigger that projects
76
+ nothing rather than omitting the line. Requires `@vectros-ai/blueprints` ≥ 0.18.0.
77
+
78
+ - **`assume` shows the identity you actually got.** `POST /v1/auth/token/assume` now returns a
79
+ `resolvedScope` alongside the re-minted token — the same plaintext data baked into the token's own
80
+ compressed `scope` claim, resolved server-side — so the human view reports the resolved `identity`
81
+ and `allowedActions` instead of leaving you to decode the token. Switching `identity` is the whole
82
+ point of assuming, so this is the direct answer to the question the command was run to ask. The
83
+ rows are omitted entirely against a backend that returns no `resolvedScope`, rather than rendered
84
+ as empty: on this command an em dash would read as "you assumed nothing". `--json` already carried
85
+ the field verbatim.
86
+
87
+ - **`role create --assignable-role <roleId>`** (repeatable) — restrict which named roles the new
88
+ role's clause may compose into a delegated AccessProfile. Omit for no restriction; the platform
89
+ rejects an empty list, so the flag has no "compose nothing" spelling and omitting it leaves the
90
+ clause byte-identical to one authored before this release. Validated locally against the platform's
91
+ roleId grammar, 20-entry cap and no-duplicates rule, so a bad list exits 2 naming the offending
92
+ entry instead of round-tripping to a `400`.
93
+
94
+ - **`access explain` reports a clause's `assignableRoles`**, in both the human and `--json` views,
95
+ for clauses that carry one. Reported alongside the access matrix rather than folded into it: the
96
+ restriction bounds role composition, not data reach, so narrowing the matrix on it would be wrong.
97
+ A blueprint can declare the same restriction via `assignableRoles` on a role or `accessProfile`
98
+ clause (`@vectros-ai/blueprints` ≥ 0.18.0), which `vectros bootstrap --blueprint` forwards.
99
+
100
+ - **`issuers register` / `issuers update` accept `--captured-claim <name>`** (repeatable) — additional
101
+ OIDC claim names, beyond the email claim, to capture from that issuer's tokens on every successful
102
+ token exchange and store as your tenant's golden identity-provider-asserted copy for the signed-in
103
+ user. `issuers update` also gains `--clear-captured-claims`, which sends an explicit empty list
104
+ (capture nothing beyond email) — distinct from omitting the flag, which leaves the stored value
105
+ unchanged. Mutually exclusive with `--captured-claim`. The list is bounds-checked locally (20
106
+ entries, 64 characters each, no duplicates), so a bad list exits 2 naming the offending entry
107
+ rather than round-tripping to a `400`. `issuers get` renders the captured names, and `issuers
108
+ list` gains a `captured claims` column.
109
+ A blueprint can declare the same thing via `issuers[].capturedClaims` (`@vectros-ai/blueprints`
110
+ ≥ 0.18.0), which `vectros bootstrap --blueprint` now forwards and reconciles when re-applied.
111
+
112
+ - **Blueprint `identityProjectionClaims` is applied.** A blueprint declaring the new top-level block
113
+ (`@vectros-ai/blueprints` ≥ 0.18.0) has it set on the app context during the bootstrap phase,
114
+ alongside the context create — that is the only phase whose credential carries the platform
115
+ provisioning capability the field requires. Declaring it selects which captured identity-provider
116
+ claim names are projected, read-only, onto access profiles in that context. A blueprint that does
117
+ not declare it sends nothing, leaving any existing declaration untouched. **It applies only when this
118
+ blueprint CREATES the context**: the platform sets the declaration on a true create, so a context
119
+ created separately beforehand never receives it, and re-applying against an existing context does
120
+ not change it in either direction — edit an existing context's
121
+ declaration through the platform API.
122
+
123
+ - **Blueprint `triggers:` provisioning.** `vectros bootstrap --blueprint` now provisions each declared trigger:
124
+ a dedicated service principal, a context-scoped AccessProfile carrying the trigger's declared
125
+ grant, and the trigger rule itself. A trigger's real declared grant is attached only after the
126
+ trigger rule is confirmed created — a failed apply never leaves a principal holding meaningful
127
+ authority with nothing referencing it. `blueprint plan` renders each trigger's firing schema/
128
+ event, script reference, and manifest (or `(none)` when absent) alongside the principal/profile
129
+ steps it provisions. Requires `@vectros-ai/blueprints` ≥ 0.18.0. A schema's
130
+ `capabilities.triggersEnabled` is now applied along with `auditHistory` — it is the opt-in a schema
131
+ must declare before a trigger may fire on its records, and a blueprint declaring a trigger against
132
+ a schema without it is refused at `plan` time rather than creating a rule that never fires.
133
+ - **Removing a trigger from a blueprint now de-provisions it.** Triggers are the one resource
134
+ `vectros bootstrap --blueprint` reconciles by *absence* as well as presence: a trigger this blueprint previously
135
+ provisioned and no longer declares has its rule and its access profile deleted, so a trigger you
136
+ removed cannot keep acting under a grant that outlived it. Its service principal is kept, so
137
+ re-adding the same trigger later reattaches to the same identity. Triggers created outside this
138
+ blueprint — by hand, or by a different blueprint sharing the app context — are never touched.
139
+ Ownership is established from **two** independent pieces of evidence, both of which must agree: the
140
+ trigger rule's own `provisionedBy` marker (which `vectros bootstrap --blueprint` stamps, and which the platform refuses to
141
+ re-point once set) and the service principal the rule names. Neither alone is sufficient, because
142
+ each is writable with a different scope; requiring both means no single credential can make one
143
+ blueprint's apply delete another principal's grant. A trigger whose two factors disagree, or whose
144
+ principal cannot be read, is left in place and reported rather than deleted. Each de-provisioned
145
+ trigger is named individually in the apply output, and `blueprint plan` states the behaviour in
146
+ its legend. No other resource is ever deleted by an apply.
147
+
148
+ ### Changed
149
+
150
+ - **A refusal caused by suspension is reported as one.** `POST
151
+ /v1/admin/keys/scoped` now answers with a `409` when the access profile or the user it names is
152
+ suspended, and the CLI renders that distinctly: no credential — root included — mints against a
153
+ suspended profile, so the remedy the `403` text points at (re-issue a broader credential) is
154
+ actively wrong here. Reactivate the profile, then retry. The developer scoped-token route answers
155
+ every refusal with one uniform `404`, and a suspended member is one of them; since every
156
+ credential-bearing verb mints through that route, the CLI names suspension among the causes rather
157
+ than reporting only that the context was not found.
158
+
159
+ - **The scope gate names `scripts` and `triggers` as control-plane.** Both were already refused for
160
+ a blueprint-issued role — they are not data-plane resources — and the refusal now says so, rather
161
+ than reading as an unrecognized resource that the allowlist might be widened for. Script authoring
162
+ and trigger declaration are deliberately admin-shaped surfaces. No scope that passed the gate
163
+ before is refused.
164
+
165
+ - **Two blueprints may not declare a trigger of the same name in one app context.** The second apply
166
+ is rejected with a `400`. A trigger fires real code under a real grant, so one blueprint quietly
167
+ re-pointing another's rule is not something an apply does. Rename one of the triggers, or move it
168
+ to its own app context.
169
+
170
+ ### Fixed
171
+
172
+ - **`role` and `access` accept `--tenant`, so a blueprint applied to the test tenant can be verified.**
173
+ Every sibling verb (`context`, `issuers`, `scripts`, `key`, `identity`) already took the flag; these
174
+ two did not, and they are the ones that read back roles and access bindings. Omitting it fell back
175
+ to the live tenant and failed with *"Context not found"* — a message naming two possible causes,
176
+ neither of which was the real one. A blueprint apply's most security-relevant output was the one
177
+ part that could not be read back from the tenant it was written to.
178
+ - **A failed token mint no longer aborts the process.** `role` and `access` now set the exit code and
179
+ return rather than exiting mid-flight, which on Windows could trip a native assertion in Node's HTTP
180
+ stack and replace the command's real exit code with `127` — the code a shell uses for "command not
181
+ found". Anything branching on exit status read a refused request as a missing binary. If you script
182
+ against another verb and see `127` from a request that clearly reached the server, that is this bug
183
+ and not a missing binary; re-run and read the printed error, which is accurate.
184
+
185
+ - **A blueprint can now grant `scripts:x:<script-name>`, so a blueprint that ships a script can also
186
+ grant permission to run it.** The scope gate classified `scripts` as control-plane by resource, so
187
+ the grant was refused outright and a blueprint could ship a script's source with no way to
188
+ authorize its execution. That reasoning is correct for `scripts:c` — authoring is a design-time,
189
+ admin-shaped surface, and it is unchanged — but it does not carry to execution, which consults no
190
+ data scope at all and whose writes are still checked one by one against the caller's own
191
+ permissions. The grant widens what can be composed, never what data is reachable.
192
+
193
+ Qualified to a script name only. A bare `scripts:x` is still rejected: execution gates on the verb
194
+ and the name, so an unqualified grant would mean "may run every script in this context, including
195
+ ones added later". `scripts:*` and `scripts:x:*` are rejected too — **there is no wildcard form.**
196
+ The qualifier is compared to the script name exactly, so a `*` matches nothing: such a grant would
197
+ store cleanly and then refuse every execution, with no error able to explain why.
198
+ - **`blueprint validate`/`plan` now catch a misspelled script grant.** If your blueprint declares a
199
+ `scripts:` block, each `scripts:x:<name>` grant is checked against the names you actually ship, and
200
+ the refusal names the role and lists what is shipped. Nothing downstream can catch this: the
201
+ platform stores a wrong name without complaint and the execution-time 403 deliberately cannot say
202
+ the name is the problem.
203
+
204
+ ## 0.20.0 — 2026-08-27
205
+
206
+ ### Added
207
+
208
+ - **The capability gate now admits `delegate-principal-stamp`**, alongside the
209
+ existing `member-lifecycle`. Grantable on an `accessProfile` or role clause's
210
+ `capabilities: [...]`, same as before — a blueprint can now let an admin
211
+ role create a membership-shaped record on behalf of a known third-party
212
+ user, once the clause authorizing the write also explicitly names `userId`
213
+ in its own `dataScope` (the capability itself may be granted on a
214
+ different clause of the same role, e.g. via a separately-composed
215
+ `roleIds` entry — only the write's own verb + `userId` admission must be
216
+ co-located). Bounded by the SAME app context and record type the clause's
217
+ `dataScope` already confines the write to — unlike `delegate-mint`/
218
+ `forensic-read`/`context-directory-read`, this is not tenant-wide, and
219
+ (like `member-lifecycle`) it is self-signup-eligible.
220
+ - **`vectros identity list-all`** — lists every user in the tenant, for the one
221
+ recovery case `identity list` can't reach: a user created but never granted
222
+ access anywhere (the gap between creating a principal and binding it to a
223
+ context). `identity list` is scoped to the caller's own context by design —
224
+ a user with no access grant at all is invisible there no matter how many
225
+ times a create is retried, since a retry with the same `--external-id` just
226
+ collides with the same invisible row. `list-all` authenticates with your own
227
+ login session (an account owner) instead of a context-scoped credential, so
228
+ it can see every user regardless of context. Supports `--external-id`,
229
+ `--tenant`, `--limit`, and `--json`, matching `identity list`'s conventions.
230
+ - **`vectros blueprint validate`/`plan`: new advisory lint —
231
+ `partial-datascope-dimension-coverage`** (needs `@vectros-ai/blueprints` ≥
232
+ 0.17.0). Warns when a role/`accessProfile` clause grants `r`/`u`/`d` on a
233
+ typeName (e.g. `records:r:case`) whose schema declares
234
+ `expectedScopeDims`, but the clause's `dataScope` names only SOME of those
235
+ dims — the missing dimension(s) are silently unconstrained for that grant,
236
+ since reads (unlike creates) never require full-dimension `dataScope`
237
+ coverage. Opt-in: a schema with no `expectedScopeDims` declared never
238
+ trips it, and it never fires on an untyped (bare `records:r`) grant.
239
+ Never runtime-enforced — pure authoring-time visibility into a choice
240
+ that's easy to make by omission rather than intent.
241
+ - **`bootstrap`/`blueprint-test` now catch a FRESH (first-apply) seed index
242
+ failure, not just a re-apply's.** A seed create's response always carries
243
+ `PENDING_INDEX` on a first apply (indexing runs async, after the
244
+ response), so the existing `seedIndexFailures` summary previously only
245
+ ever saw a terminal `FAILED` on a later idempotent re-apply — a
246
+ newly-bootstrapped demo whose seeds failed to index looked silently empty
247
+ in search/RAG until the operator happened to re-run bootstrap. Every seed
248
+ still `PENDING_INDEX` after seeding is now re-checked with a bounded,
249
+ concurrent, best-effort poll (default: 3 attempts, 500ms apart, ≤ ~1.5s
250
+ added to the happy path) and folded into the same run-summary warning if
251
+ it settles to `FAILED` within the bound. Deliberately NOT a guarantee — a
252
+ seed still `PENDING_INDEX` when the bound is exhausted stays silent, so a
253
+ genuinely slow index (reconcile/quarantine can stall for minutes) never
254
+ turns a one-shot provisioning command into an unbounded wait.
255
+ - **`vectros issuers` gains `register`/`update`/`delete`** — previously
256
+ read-only (`list`/`get`). Closes the gap where adding a second IdP, or
257
+ editing one issuer's `selfSignupPolicies` after initial setup, required
258
+ hand-authoring a blueprint `issuers:` block and re-running a full apply.
259
+ `register` is idempotent by issuer id (a repeat returns the existing
260
+ registration unchanged, not an error); `update` only ever sends the safe,
261
+ mutable fields (`subClaim`/`emailClaim`/`userinfoUri`/`status`/
262
+ `selfSignupPolicies`) — trust-anchor fields stay immutable via this route,
263
+ matching the platform; `delete` uses the same re-type-the-id confirmation
264
+ as `context destroy` (`--force` for non-interactive use), and is refused
265
+ server-side while any user is bound through the issuer. All three accept
266
+ `--tenant test|live` (like their `list`/`get` siblings) so a context
267
+ living in the `test` tenant is reachable, not just `live`.
268
+ - **New `vectros namespace` command group: `register`/`list`/`get`.**
269
+ Registering a brand-new scope namespace (e.g. a `practice`/`team` entity
270
+ dimension) was blueprint-apply-only before this; there was also no way to
271
+ discover which namespaces already exist without already knowing their
272
+ names. `register --entity-backed` is what makes a namespace a real
273
+ identity-entity dimension that `identity create --type <name>` can create
274
+ entities in. No `update`/`delete` verbs — both are gated to a genuine root
275
+ API key on the platform side with no provisioning-capability door, so
276
+ neither could ever succeed for any credential the CLI holds (documented in
277
+ the command's own module header).
278
+ - **`role create` gains `--assumable <json>`** — a role-level `vectros
279
+ assume` entitlement grant (JSON map of `scope:<namespace>` → allowed
280
+ values), previously blueprint-`roleAssumable:`-only. Independent of
281
+ `--data-scope`: holding broad data-scope reach in a namespace does not by
282
+ itself grant assuming any value in it.
283
+ - **New `vectros assume --context <c> --scope <ns:value>...`** — re-mints
284
+ the caller's own verb token with one or more `identity.<namespace>` values
285
+ switched (`POST /v1/auth/token/assume`), for a caller whose role
286
+ explicitly grants assuming those values. The CLI's first standalone
287
+ command reaching this endpoint; entitlement is checked live against the
288
+ caller's current roles, never a copy frozen into a token at mint time. No
289
+ `--to-keyring` — the response is an ephemeral bearer token with the same
290
+ remaining lifetime as the verb token this command itself mints to make the
291
+ call, not a durable secret.
292
+
293
+ ### Changed
294
+
295
+ - **`runLoader`'s `provisioned` is now REQUIRED — the self-provisioning
296
+ fallback is gone.** Internal hardening only, no CLI-facing behavior
297
+ change: every real caller (`vectros bootstrap`, `blueprint-test`) already
298
+ always ran `provisionContext` first and passed the result in, so the
299
+ fallback branch was dead in production. Removing it makes the
300
+ `provisionContext` → (apply-time identity resolution) → `runLoader`
301
+ ordering structurally required instead of convention-enforced — a future
302
+ caller can no longer reproduce the ordering bug the original fix
303
+ closed by skipping the split. `allowTenantWideNamespaces` and
304
+ `confirmExistingPrincipal` moved with it: both now live only on
305
+ `provisionContext`'s own input, where their actual checks have run since
306
+ that original fix — `runLoader`'s own copies of both parameters were already dead
307
+ plumbing before this change.
308
+ - **Apply-time identity resolution now REQUIRES `provisionContext`'s result.**
309
+ Internal hardening only, no CLI-facing behavior change. Closes the other
310
+ half of the ordering gap above: `runLoader` already required
311
+ `provisionContext`'s output, but the step between them — resolving a
312
+ blueprint's declared `identities:` — took a bare client with nothing tying
313
+ it to `provisionContext` having run first, so a caller could still
314
+ reproduce the original ordering bug (a non-`user` identity in a namespace
315
+ the same blueprint also registers racing that registration) with no
316
+ compiler signal. It now derives which client to use internally instead of
317
+ each caller doing so independently.
318
+
319
+ ### Fixed
320
+
321
+ - **`bootstrap`'s post-merge hint is now blueprint-specific, and `--client
322
+ code` always shows the `claude mcp add` command.** The restart hint used to
323
+ read "list my tasks" / "add a task to …" for EVERY blueprint (a leftover
324
+ from when only `task-management` existed) — it now names the actual
325
+ blueprint applied. The `claude mcp add` line (the idiomatic Claude Code
326
+ install path) used to print only on `--no-merge` / a declined / a failed
327
+ merge — a successful auto-merge silently hid it; it's now shown there too.
328
+ - Scrubbed internal issue/MR references, review-process citations, and
329
+ backend class names out of `packages/cli`'s comments — visible in the
330
+ published npm package's sourcemaps to anyone who reads mapped source.
331
+ Comment-only; no behavior change.
332
+ - **Keyring secret-store hardening.** A 0-byte salt file removed by
333
+ something else mid-poll (a concurrent cleanup, a manual delete of the
334
+ crash remnant) used to throw a raw `ENOENT` out of `setSecret`; it now
335
+ restarts salt creation instead, since nothing was ever encrypted under an
336
+ empty salt in the first place. Also fixed an order-dependent assumption
337
+ in two `keyring prune` tests (they now pin the processing order they
338
+ depend on, so a future ordering change fails with a diagnosable message
339
+ instead of a confusing downstream one).
340
+
341
+ ### Changed
342
+
343
+ - Bundled `@vectros-ai/sdk` refreshed to the current release's staging build.
344
+
6
345
  ## 0.19.0 — 2026-08-27
7
346
 
8
347
  ### Added