@vectros-ai/cli 0.20.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
@@ -298,7 +302,7 @@ namespaces:
298
302
  # Registered in the SAME bootstrap-token phase as issuers, before schemas.
299
303
  # Owned by this blueprint's own contextId (above) by default. `tenantWide: true` opts
300
304
  # out of that, but declaring it is not enough on its own: the applying credential must
301
- # 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
302
306
  # it without the explicit `--allow-tenant-wide-namespaces` flag — a blueprint cannot make
303
307
  # this happen by itself. Registration is create-once, NOT idempotent server-side: a
304
308
  # re-apply matching every declared field converges silently, but one that disagrees
@@ -449,6 +453,90 @@ so if you seed a referencing record, seed its target first. To query "which X
449
453
  reference Y", add the reference field to `lookupFields` as an equality lookup
450
454
  (there is no reverse-reference index on this surface).
451
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
+
452
540
  ### The scope gate — what you can and can't request
453
541
 
454
542
  The gate allows only **data-plane** resources:
@@ -457,11 +545,35 @@ The gate allows only **data-plane** resources:
457
545
  registered — ownership-gated the same way as `records`; it grants no
458
546
  authority over the namespace *registry* itself, which stays root-key-only).
459
547
  Operations are `c|r|u|d`, combinable (`records:cru`), with a per-resource
460
- wildcard (`records:*`). Anything else — control-plane resources or the bare
548
+ wildcard (`records:*`). Anything else — control-plane resources and the bare
461
549
  `*` — is rejected, and there is **intentionally no override flag**. This is
462
550
  *why* it's safe to apply a blueprint you didn't write: the binary is the trust
463
551
  boundary, not the file.
464
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
+
465
577
  ### Capabilities (`granted_capabilities`) — a separate, equally-gated dimension
466
578
 
467
579
  An `accessProfile` or role clause may also declare `capabilities: [...]` —
@@ -505,6 +617,24 @@ from a blueprint's `externalId` colliding with an unrelated identity, so it asks
505
617
  rather than guessing either way. `--rotate` is exempt only when the local
506
618
  record already names the same principal being rotated.
507
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
+
508
638
  ## Distribution
509
639
 
510
640
  - **Bundled** — reviewed, versioned blueprints shipped in the library
package/CHANGELOG.md CHANGED
@@ -3,6 +3,204 @@
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
+
6
204
  ## 0.20.0 — 2026-08-27
7
205
 
8
206
  ### Added
package/README.md CHANGED
@@ -13,6 +13,8 @@ The Vectros provisioning CLI — the `vectros` binary:
13
13
  - **`vectros namespace`** — register a new scope namespace, and list/get existing ones.
14
14
  - **`vectros identity`** — manage tenant-wide principals (users, orgs, clients, and namespaces you register).
15
15
  - **`vectros role`** — define reusable, context-scoped access rules.
16
+ - **`vectros scripts`** — push, list, inspect, and delete the versioned script source a trigger
17
+ fires, or your own app executes directly.
16
18
  - **`vectros assume`** — switch identity namespace values on your own token, per a role's `assumable` grant.
17
19
  - **`vectros access`** — bind principals to contexts, and inspect what a binding grants.
18
20
  - **`vectros key`** — issue, list, revoke, and rotate scoped API keys (`ssk_*`).
@@ -162,7 +164,8 @@ requires hand-authoring a blueprint and re-running a full apply.
162
164
  vectros issuers register --context my-app --issuer-id auth0-prod \
163
165
  --issuer https://your-tenant.us.auth0.com/ \
164
166
  --jwks-uri https://your-tenant.us.auth0.com/.well-known/jwks.json \
165
- --audience https://api.your-app.example.com
167
+ --audience https://api.your-app.example.com \
168
+ --captured-claim name --captured-claim phone_number
166
169
  vectros issuers list --context my-app
167
170
  vectros issuers get primary --context my-app
168
171
  vectros issuers update primary --context my-app --status suspended
@@ -171,11 +174,11 @@ vectros issuers delete primary --context my-app # re-types the id to confirm
171
174
 
172
175
  | Subcommand | Flags | Notes |
173
176
  |---|---|---|
174
- | `register` | `--context`, `--issuer-id`, `--issuer`, `--jwks-uri`, `--audience` (all required); `--sub-claim`, `--email-claim`, `--userinfo-uri`, `--self-signup-policy <type:roleId>` (repeatable), `--tenant test\|live` | Idempotent by `--issuer-id` within the tenant — a repeat returns the existing registration unchanged, not an error. |
175
- | `update <issuerId>` | `--context <contextId>` (required); `--sub-claim`, `--email-claim`, `--userinfo-uri`, `--status active\|suspended`, `--self-signup-policy <type:roleId>` (repeatable), `--clear-self-signup-policies`, `--tenant test\|live` | Only ever sends the SAFE, mutable fields — `issuer`/`jwks-uri`/`audience`/`contextId` are trust-anchor fields, immutable via this route on the platform side; rotating one is `delete` + `register`. |
177
+ | `register` | `--context`, `--issuer-id`, `--issuer`, `--jwks-uri`, `--audience` (all required); `--sub-claim`, `--email-claim`, `--userinfo-uri`, `--self-signup-policy <type:roleId>` (repeatable), `--captured-claim <name>` (repeatable), `--tenant test\|live` | Idempotent by `--issuer-id` within the tenant — a repeat returns the existing registration unchanged, not an error. `--captured-claim` names additional OIDC claims to capture beyond the email claim on every successful token exchange, stored as your tenant's golden identity-provider-asserted copy for that user; omit it to capture nothing beyond email. At most 20 names of at most 64 characters, no duplicates. |
178
+ | `update <issuerId>` | `--context <contextId>` (required); `--sub-claim`, `--email-claim`, `--userinfo-uri`, `--status active\|suspended`, `--self-signup-policy <type:roleId>` (repeatable), `--clear-self-signup-policies`, `--captured-claim <name>` (repeatable), `--clear-captured-claims`, `--tenant test\|live` | Omitting `--captured-claim` leaves the stored list UNCHANGED; `--clear-captured-claims` sends an explicit empty list (capture nothing beyond email). The two are mutually exclusive, and the same absent-vs-clear split applies to `--self-signup-policy`. Only ever sends the SAFE, mutable fields — `issuer`/`jwks-uri`/`audience`/`contextId` are trust-anchor fields, immutable via this route on the platform side; rotating one is `delete` + `register`. |
176
179
  | `delete <issuerId>` | `--context <contextId>` (required), `--force`, `--tenant test\|live` | Re-types the issuer id to confirm (mirrors `context destroy`); `--force` skips the prompt and is required when stdin isn't a TTY. Refused server-side while any user was ever created or matched through this issuer. |
177
180
  | `list` | `--context <contextId>` (required), `--tenant test\|live` | Every issuer registered in that context. `--context` has no default — an issuer read is confined to the context you name; there's no tenant-wide "every issuer in every context" view. |
178
- | `get <issuerId>` | `--context <contextId>` (required), `--tenant test\|live` | One issuer's full registration, including its self-signup policies. |
181
+ | `get <issuerId>` | `--context <contextId>` (required), `--tenant test\|live` | One issuer's full registration, including its self-signup policies and captured claims. |
179
182
 
180
183
  All five accept `--json`, plus the common auth flags (`--base-url`, `--token`).
181
184
 
@@ -252,6 +255,8 @@ vectros role create --context my-app --role-id own-org --name "Own org" \
252
255
  --actions records:r --data-scope '{"scope:org":["<orgId>", null]}'
253
256
  vectros role create --context my-app --role-id hr-admin --name "HR Admin" \
254
257
  --actions records:cru --assumable '{"scope:org":["org_engineering", "org_sales"]}'
258
+ vectros role create --context my-app --role-id delegator --name Delegator \
259
+ --actions profiles:c --assignable-role editor --assignable-role own-org
255
260
  vectros role list --context my-app
256
261
  vectros role get --context my-app --role-id editor
257
262
  vectros role delete --context my-app --role-id editor
@@ -259,13 +264,65 @@ vectros role delete --context my-app --role-id editor
259
264
 
260
265
  | Subcommand | Flags | Notes |
261
266
  |---|---|---|
262
- | `create` | `--context <c>`, `--role-id <id>`, `--name <n>`, `--actions <csv>` (all required); `--data-scope <json>`, `--assumable <json>`, `--description <text>` | Idempotent by `roleId`. `--data-scope` confines the clause to owned data — a JSON map of ownership dimension → allowed values, ANDed across keys (e.g. `{"userId":["${{ self.userId }}"]}`, or `{"scope:org":["<orgId>", null]}` — the `null` entry additionally admits scope-less/tenant-shared records). Keys must be `userId` or `scope:<namespace>` (the legacy flat `orgId`/`clientId` keys are rejected server-side). `--assumable` is a SEPARATE, role-level grant — a JSON map of `scope:<namespace>` → allowed values naming which values a holder of this role may switch into via `vectros assume`; holding broad `--data-scope` reach in a namespace does not by itself grant assuming any value in it. |
263
- | `list` | `--context <c>` (required) | |
264
- | `get` | `--context <c>`, `--role-id <id>` (both required) | Also shows the role's `assumable` grant, if any is set. |
265
- | `delete` | `--context <c>`, `--role-id <id>` (both required) | No cascade — any access profile still referencing the role fails to mint (not silently downgraded) until reassigned. |
267
+ | `create` | `--context <c>`, `--role-id <id>`, `--name <n>`, `--actions <csv>` (all required); `--data-scope <json>`, `--assumable <json>`, `--assignable-role <roleId>` (repeatable), `--description <text>`, `--tenant test\|live` | Idempotent by `roleId`. `--assignable-role` restricts WHICH named roles this clause may compose into a delegated AccessProfile — orthogonal to `--data-scope`, which bounds data reach rather than role composition. Omit it for no restriction; the platform rejects an empty list, so there is no way to spell "compose nothing". Once a clause carries the restriction it can compose only the roles it names, so name every roleId it still needs before adding the flag. `access explain` reports the restriction for clauses that carry one. `--data-scope` confines the clause to owned data — a JSON map of ownership dimension → allowed values, ANDed across keys (e.g. `{"userId":["${{ self.userId }}"]}`, or `{"scope:org":["<orgId>", null]}` — the `null` entry additionally admits scope-less/tenant-shared records). Keys must be `userId` or `scope:<namespace>` (the legacy flat `orgId`/`clientId` keys are rejected server-side). `--assumable` is a SEPARATE, role-level grant — a JSON map of `scope:<namespace>` → allowed values naming which values a holder of this role may switch into via `vectros assume`; holding broad `--data-scope` reach in a namespace does not by itself grant assuming any value in it. |
268
+ | `list` | `--context <c>` (required); `--tenant test\|live` | Add `--tenant test` to read a context that lives in the test tenant — a blueprint applied there is otherwise unreadable. |
269
+ | `get` | `--context <c>`, `--role-id <id>` (both required); `--tenant test\|live` | Also shows the role's `assumable` grant, if any is set. |
270
+ | `delete` | `--context <c>`, `--role-id <id>` (both required); `--tenant test\|live` | No cascade — any access profile still referencing the role fails to mint (not silently downgraded) until reassigned. |
266
271
 
267
272
  `create`/`list`/`get` accept `--json`; `delete` does not.
268
273
 
274
+ ## `vectros scripts` — the script source a trigger fires, or your app runs directly
275
+
276
+ A stored script has two consumers, and this is how the source gets there for both. A **trigger rule**
277
+ names it in `scriptRef` and runs it under the trigger's own grant; that `scriptRef` is validated when
278
+ the rule is DECLARED rather than when it fires, so a script has to exist before any rule can name it.
279
+ **Your own app** runs a stored version directly through `POST /v1/scripts/execute`, under its own
280
+ credential, committing everything the script wrote as a single transaction.
281
+
282
+ Direct execution is separately granted: the calling role needs `scripts:x`, and without it the call
283
+ is a `403` that cannot say the missing grant is the reason. The platform accepts it bare (every
284
+ script in the context) or qualified to one name (`scripts:x:create-case`). A **blueprint** may use
285
+ only the qualified form — its scope gate refuses a bare `scripts:x` and there is no wildcard — so a
286
+ blueprint that ships a script grants exactly the one it ships. See *The scope gate* below, and
287
+ [`AUTHORING.md`](./AUTHORING.md) for where that grant goes in a blueprint.
288
+
289
+ ```bash
290
+ vectros scripts push notify-assignee --context my-app --source-file ./notify.js
291
+ vectros scripts push notify-assignee --context my-app --source-file ./notify.js --if-changed
292
+ vectros scripts list --context my-app # every script, newest first
293
+ vectros scripts list --context my-app --name notify-assignee # every version of one name
294
+ vectros scripts get 3b1c9f2e7a5d… --context my-app # one version, with its source
295
+ vectros scripts delete 3b1c9f2e7a5d… --context my-app # ONE version (prompts)
296
+ vectros scripts delete 3b1c9f2e7a5d… --context my-app --force # ...or skip the prompt, e.g. in CI
297
+ ```
298
+
299
+ | Subcommand | Flags | Notes |
300
+ |---|---|---|
301
+ | `push` | `--context <c>`, `--source-file <path>` (both required); `--input-contract <text>` or `--input-contract-file <path>`, `--if-changed`, `--tenant <test\|live>` | **Always stores a new version by default** — pushing is not idempotent, and each push gets the next server-assigned version number, which the output reports. `--if-changed` reads the current version first and pushes only on a genuine difference; use it where the same push re-runs, e.g. in CI. `--source-file` is capped at 300,000 UTF-8 bytes and the input contract at 50,000, both checked locally so an oversized push fails immediately instead of sending the body. The input contract is descriptive only — nothing validates a firing against it. Nothing parses the source either, so a syntax error surfaces the first time the version runs. |
302
+ | `list` | `--context <c>` (required); `--name <name>`, `--tenant <test\|live>` | With `--name`, every version of that name, oldest first; without, every script in the context, newest first. The human view shows a byte count instead of the source — a listing would otherwise print the full text of every version. `--json` includes the source. |
303
+ | `get` | `--context <c>` (required) + the version id; `--tenant <test\|live>` | Shows one version, including its full source. Version ids are opaque — a deterministic hex string, not a prefixed or random id — so copy them from `scripts list`. |
304
+ | `delete` | `--context <c>` (required) + the version id; `--force`, `--tenant <test\|live>` | Deletes ONE version; other versions of the same name are untouched. **Irreversible — the stored source is the only copy** — so it asks you to type the version id back before proceeding. `--force` skips that, and is REQUIRED in a non-interactive shell rather than the prompt being silently waived there. `--json` emits `{id, deleted}` on stdout; the warning always goes to stderr, so it stays visible without breaking a pipe. ⚠️ Deleting a name's LATEST version reuses its identity — the next push of that name recomputes the same version number and the same id, so a pinned reference silently resolves to different source. |
305
+
306
+ There is no `scripts update` — a stored version is immutable, and the platform's update route always
307
+ rejects. Supersede a version by pushing a new one.
308
+
309
+ `--context` is required on all four. Scripts are context-scoped and a trigger resolves its
310
+ `scriptRef` inside its own context, so a defaulted context would push where nobody asked and leave
311
+ the rule that wanted it still unresolvable. `--tenant` defaults to `live`; pass `--tenant test` to
312
+ reach the versions a `blueprint-test --tenant test` run created.
313
+
314
+ ⚠️ **`delete` is not reference-checked.** Nothing verifies whether a trigger rule still points at the
315
+ version being removed, and the two outcomes differ: a rule pinned to that version fails on every
316
+ subsequent firing, while a rule on `version: latest` silently resolves to the PREVIOUS version and
317
+ carries on running older code with nothing to indicate it. This CLI has no verb that lists trigger
318
+ rules, so it cannot answer that question for you — check against the API before removing a version a
319
+ rule may still use.
320
+
321
+ A blueprint can ship a script instead of pushing one — see `scripts:` in
322
+ [`@vectros-ai/blueprints`](https://www.npmjs.com/package/@vectros-ai/blueprints). An apply is
323
+ convergent (an unchanged source pushes nothing) and never deletes: removing a `scripts:` entry
324
+ leaves every version in place, which is why this command group exists to remove one deliberately.
325
+
269
326
  ## `vectros assume` — switch identity namespace values on your own token
270
327
 
271
328
  Re-mints your own verb token with one or more `identity.<namespace>` values
@@ -283,7 +340,7 @@ vectros assume --context my-app --scope org:org_sales
283
340
  |---|---|
284
341
  | `--context <c>` (required), `--scope <ns:value>` (repeatable, at least one required) | Each `--scope` becomes one `scope:<namespace>` entry in the request. No artificial cap on how many namespaces you name — the platform requires a SINGLE role to jointly grant the whole combination and rejects an unsatisfiable one itself. |
285
342
 
286
- Prints the re-minted `access_token`/`expires_in` (accepts `--json`) — there
343
+ Prints the re-minted `access_token`/`expires_in`, plus the resolved `identity` and `allowedActions` the platform returns alongside it — what you actually assumed, without decoding the token (accepts `--json`) — there
287
344
  is no `--to-keyring` here, since the result is an ephemeral bearer token
288
345
  with the same remaining lifetime as the verb token this command itself
289
346
  mints to make the call, not a durable secret the keyring is built to hold.
@@ -313,11 +370,11 @@ shares this grammar.
313
370
 
314
371
  | Subcommand | Flags | Notes |
315
372
  |---|---|---|
316
- | `grant` | `--principal <p>`, `--context <c>` (required); exactly one of `--role <roleId>` (repeatable — 2+ compose additively) / `--actions <csv>`; `--scope <ns:value>` (repeatable), `--identity-overrides <json>` | Idempotent **upsert** — re-granting an existing binding updates it to the newly-declared shape (role(s)/actions/overrides), it does not no-op. Repeating `--role` composes: `--role case-handler --role hr-admin` grants each role's own clauses, concatenated in the order given — never merged, so each clause keeps meaning exactly what its own author wrote. `--scope`/`--identity-overrides` extend the principal's stamped identity (e.g. `--scope org:<id>` → `scope:org`) — a credential can only stamp ownership its identity carries, so granting write access to org-scoped data requires that scope on the profile. When both are given, `--identity-overrides` wins on key conflicts. |
317
- | `revoke` | `--principal <p>`, `--context <c>` (required) | |
318
- | `list` | Exactly one of `--context <c>` / `--principal <p>` | `--context` lists the context's members; `--principal` lists every context that principal is bound to. |
319
- | `get` | `--principal <p>`, `--context <c>` (required) | |
320
- | `explain` | `--principal <p>`, `--context <c>` (required) | Resolves the binding's clauses (following every referenced role and concatenating their clauses, if bound via one or more) and renders a concrete **access matrix** — a canned set of record-ownership archetypes (yours alone, another user's, org-shared, scope-less, …) evaluated against the clauses. A client-side preview for authoring-time sanity, not the platform's own evaluator: it models READ reachability only — a `c`/`u`/`d` action shown as granted is advisory, not a guarantee a specific write will succeed, since the platform's write rule (a clause must explicitly name a dimension to place a value into it) diverges from its read rule (an unmentioned dimension never narrows reads). |
373
+ | `grant` | `--principal <p>`, `--context <c>` (required); exactly one of `--role <roleId>` (repeatable — 2+ compose additively) / `--actions <csv>`; `--scope <ns:value>` (repeatable), `--identity-overrides <json>`, `--tenant test\|live` | Idempotent **upsert** — re-granting an existing binding updates it to the newly-declared shape (role(s)/actions/overrides), it does not no-op. Repeating `--role` composes: `--role case-handler --role hr-admin` grants each role's own clauses, concatenated in the order given — never merged, so each clause keeps meaning exactly what its own author wrote. `--scope`/`--identity-overrides` extend the principal's stamped identity (e.g. `--scope org:<id>` → `scope:org`) — a credential can only stamp ownership its identity carries, so granting write access to org-scoped data requires that scope on the profile. When both are given, `--identity-overrides` wins on key conflicts. |
374
+ | `revoke` | `--principal <p>`, `--context <c>` (required); `--tenant test\|live` | |
375
+ | `list` | Exactly one of `--context <c>` / `--principal <p>`; `--tenant test\|live` | `--context` lists the context's members; `--principal` lists every context that principal is bound to. |
376
+ | `get` | `--principal <p>`, `--context <c>` (required); `--tenant test\|live` | |
377
+ | `explain` | `--principal <p>`, `--context <c>` (required); `--tenant test\|live` | Resolves the binding's clauses (following every referenced role and concatenating their clauses, if bound via one or more) and renders a concrete **access matrix** — a canned set of record-ownership archetypes (yours alone, another user's, org-shared, scope-less, …) evaluated against the clauses. A client-side preview for authoring-time sanity, not the platform's own evaluator: it models READ reachability only — a `c`/`u`/`d` action shown as granted is advisory, not a guarantee a specific write will succeed, since the platform's write rule (a clause must explicitly name a dimension to place a value into it) diverges from its read rule (an unmentioned dimension never narrows reads). |
321
378
 
322
379
  `grant`/`list`/`get`/`explain` accept `--json`; `revoke` does not.
323
380
 
@@ -473,11 +530,11 @@ CLI runs under an elevated ephemeral bridge token. So the trust boundary is
473
530
  **this binary, not the blueprint**: a hardcoded **scope gate** bounds every
474
531
  mint to a data-plane allowlist (`records` / `schemas` / `search` / `documents`
475
532
  / `folders` / `inference` / `entities`). Most control-plane scope (keys /
476
- app-contexts / users / billing / admin / namespaces) or a `*` wildcard
477
- **hard-rejects** — the CLI mints nothing and exits non-zero. There is
533
+ app-contexts / users / billing / admin / namespaces / triggers) or a
534
+ `*` wildcard **hard-rejects** — the CLI mints nothing and exits non-zero. There is
478
535
  intentionally **no override flag**.
479
536
 
480
- **One narrow, deliberate exception:** `profiles` (which configures who has
537
+ **Two narrow, deliberate exceptions. The first:** `profiles` (which configures who has
481
538
  access to what — control-plane by nature) admits its `r`/`u`/`d` operations,
482
539
  because the platform enforces an unconditional check that a write to an
483
540
  AccessProfile can never grant more scope than the requesting credential
@@ -495,6 +552,16 @@ requires. A literal-qualified `profiles:c:usr_<id>` naming another principal
495
552
  still hard-rejects regardless of `member-lifecycle`, same as every other
496
553
  control-plane scope.
497
554
 
555
+ **The second exception:** `scripts:x:<script-name>` — execute one named script — is admitted, so a
556
+ blueprint that ships a script in its `scripts:` block can also grant permission to run it. Execution
557
+ consults no data scope at all, and every write the script makes is still checked one by one against
558
+ the caller's own permissions, so this widens *what can be composed*, never *what data is reachable*.
559
+ Everything else on the resource still hard-rejects: a bare `scripts:x`, because it would mean "run
560
+ every script in this context, including ones added later that your reviewer never saw"; `scripts:x:*`,
561
+ because there is **no wildcard form** — the qualifier is compared to the script name exactly, so such
562
+ a grant would store cleanly and then refuse every execution; and `scripts:c`, authoring, which stays
563
+ a design-time admin surface. `triggers` is unchanged.
564
+
498
565
  ### Capabilities (`granted_capabilities`) — a separate, equally-gated dimension
499
566
 
500
567
  A blueprint's `accessProfile` and role clauses may also declare