@vectros-ai/cli 0.15.0 → 0.19.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
@@ -49,14 +49,24 @@ A name must be 2–50 chars: a lowercase letter, then lowercase letters / digits
49
49
 
50
50
  1. **Structural** — the file parses against the blueprint schema (readable
51
51
  `path: message` errors, e.g. `schemas[0].fields[1]: Required`).
52
- 2. **Scope gate** — the requested `allowedActions` are data-plane only. This is
53
- the trust boundary: control-plane scopes (`keys`, `profiles`,
54
- `users`, `admin`, …) and the `*` wildcard are **hard-rejected, no override**.
55
- A forked or hand-written blueprint gets the *identical* gate a bundled one
56
- does — it can never escalate.
52
+ 2. **Scope + capability gates** — the requested `allowedActions` are data-plane
53
+ only. This is the trust boundary: `keys`/`users`/`admin`/`app-contexts`/
54
+ `namespaces` and the `*` wildcard are **hard-rejected, no override**.
55
+ `profiles` is a narrower exception — `r`/`u`/`d` are admitted, and `c` only
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
58
+ requested `capabilities` are checked the same way, against their own,
59
+ separate allowlist — see "Capabilities" below. A forked or hand-written
60
+ blueprint gets the *identical* gates a bundled one does — it can never
61
+ escalate.
57
62
  3. **Lint** — semantic checks: duplicate `fieldId`, a `lookupField` naming a
58
63
  field that doesn't exist, a `seed` for a typeName with no schema (errors);
59
- `enumValues` on an incompatible type, and the dataScope null-sentinel nudge
64
+ `enumValues` on an incompatible type, the dataScope null-sentinel nudge,
65
+ and a role/`accessProfile` clause that omits `dataScope` **entirely** on
66
+ an owner-scoped read/write action — a stronger nudge than the null-
67
+ sentinel one, since it means unconstrained reach across every owner, not
68
+ just a narrower gap in an existing constraint. Say so explicitly with
69
+ `dataScope: {}` when a shared/tenant-wide resource is what you intend
60
70
  (warnings — they print but don't fail).
61
71
 
62
72
  `validate` accepts a file path **or** a bundled blueprint name.
@@ -77,6 +87,30 @@ A name must be 2–50 chars: a lowercase letter, then lowercase letters / digits
77
87
  + seed 1 record(s)
78
88
  ```
79
89
 
90
+ **A blueprint whose `accessProfile` composes roles (`roleIds:`) previews every
91
+ composed role's clauses individually**, not flattened, because two clauses
92
+ sharing an action set can still carry different `dataScope`:
93
+
94
+ ```
95
+ Plan for blueprint 'my-thing' (v1.0.0) → context 'mcp':
96
+ + schema 'thing' (2 fields, HYBRID) lookup: [externalId]
97
+ + context 'mcp' (MCP — my-thing)
98
+ + principal 'my-thing-sp' (SERVICE)
99
+ + role 'case-handler' (1 clause, actions [records:cru])
100
+ clause 1: actions [records:cru], dataScope: scope:org=[${{ self.scope.org }}]
101
+ + role 'hr-admin' (1 clause, actions [records:r])
102
+ clause 1: actions [records:r], dataScope: (none — unconstrained)
103
+ assumable: scope:org=[org_engineering, org_sales]
104
+ + profile via role(s) [case-handler, hr-admin] (composed — each role's own clauses, concatenated in order)
105
+ + key my-thing-<machine> (mint 1 scoped ssk_*)
106
+ + seed 1 record(s)
107
+ ```
108
+
109
+ A role's `assumable` line only appears when the blueprint's top-level
110
+ `roleAssumable:` map names that role — it's shown because it's
111
+ security-relevant (what a holder of the role may become via `POST
112
+ /v1/auth/token/assume`), not just that the role was declared.
113
+
80
114
  `bootstrap` shows the same plan and asks for confirmation before minting
81
115
  (`--yes` skips the prompt for CI/agents). ⚠️ **`--no-merge` is NOT a dry run** — it still
82
116
  mints/reconciles for real; it only changes where the resulting credential goes, printing the MCP
@@ -154,13 +188,51 @@ accessProfile:
154
188
  # owners' records, not shared/seed data. Keys are userId + namespaced scopes
155
189
  # (scope:org, scope:client, ...):
156
190
  # dataScope: { "scope:org": [<org-uuid>, null] }
191
+ # Omitting dataScope ENTIRELY is different from an empty dataScope: {} —
192
+ # both mean "every owner", but only {} says so on purpose. `validate` warns
193
+ # on the omitted-key form so an author who meant "my own org" and forgot
194
+ # the key notices before minting; write dataScope: {} to say the reach is
195
+ # deliberate.
196
+ # capabilities: [member-lifecycle]
197
+ # A SEPARATE gated dimension from allowedActions — named platform effects a
198
+ # 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.
201
+ #
202
+ # Instead of allowedActions (+ optional dataScope/capabilities), accessProfile
203
+ # may declare `roleIds: [<roleId>, ...]` — one or more roles declared below,
204
+ # in `roles:`, composed additively (each role's own clauses, concatenated in
205
+ # the order listed; a single roleId is equivalent to the platform's
206
+ # deprecated singular roleId reference). Mutually exclusive with allowedActions; a roleIds-
207
+ # composed profile has no inline clause of its own, so dataScope/capabilities
208
+ # are rejected alongside it — author those on the referenced role instead:
209
+ # accessProfile:
210
+ # roleIds: [case-handler, hr-admin]
211
+
212
+ roles:
213
+ # Optional. Reusable, identity-agnostic access rules — DISTINCT from
214
+ # accessProfile (which scopes only the blueprint's own service-principal
215
+ # key). A role is provisioned but bound to no one by `bootstrap`; bind it
216
+ # afterward with `vectros access grant --principal <p> --context <c> --role
217
+ # <roleId>`, or let a human self-enroll with `vectros join <context> --role
218
+ # <roleId>`. Each roleId maps to an ORDERED LIST of clauses — a record is
219
+ # accessible when ANY clause grants it, and within one clause every
220
+ # dataScope dimension must match (the same allowedActions/dataScope/
221
+ # capabilities shape as accessProfile, three-part, evaluated per-clause):
222
+ editor:
223
+ - allowedActions: [records:cru, records:d, search:r, schemas:r]
224
+ dataScope: {} # explicit marker for tenant-wide (unscoped) reach
225
+ member:
226
+ - allowedActions: [records:r, search:r]
227
+ dataScope: { userId: ['${{ self.userId }}'] } # self-scoped: only your own records
157
228
 
158
229
  servicePrincipal:
159
230
  externalId: my-thing-sp # stable; the principal the profile + key bind to
160
231
  displayName: My Thing
161
232
 
162
233
  seed:
163
- - typeName: thing
234
+ - surface: record # 'record' | 'document' — the seed's own discriminator
235
+ typeName: thing
164
236
  externalId: seed-welcome # stable → idempotent; re-apply converges
165
237
  fields: { title: "Hello" }
166
238
  # Seeds are illustrative sample data. They're applied by default; provision a
@@ -172,7 +244,7 @@ issuers:
172
244
  issuer: "https://your-tenant.us.auth0.com/"
173
245
  jwksUri: "https://your-tenant.us.auth0.com/.well-known/jwks.json"
174
246
  audience: "https://api.your-app.example.com" # globally unique per (issuer, audience)
175
- contextId: my-thing # which app context an exchanged token targets
247
+ contextId: mcp # MUST equal this blueprint's own contextId (above)
176
248
  # Optional trusted third-party IdP issuers (BYO-IdP token exchange). Applied
177
249
  # in the BOOTSTRAP-token phase, alongside app-context/service-principal
178
250
  # creation — NOT the same phase as schemas/accessProfile/roles above (see
@@ -180,6 +252,77 @@ issuers:
180
252
  # is create-once: re-applying an already-registered issuerId leaves it
181
253
  # unchanged — there's no update, so an edited jwksUri/audience on a live
182
254
  # issuer won't take effect via re-apply.
255
+ #
256
+ # Each entry's contextId must equal the blueprint's own contextId. An issuer
257
+ # is a trust anchor — whoever controls its jwksUri can mint identities your
258
+ # tenant accepts — so a blueprint may only attach one to the context it
259
+ # actually provisions. One IdP account serving several contexts needs one
260
+ # entry per context anyway, because (issuer, audience) must be unique; put
261
+ # each in that context's own blueprint.
262
+
263
+ namespaces:
264
+ - namespace: team # 2-32 chars, lowercase letter first; reserved names
265
+ # (user, record, document, entity, self, tenant,
266
+ # context, scope, versions, lookup) are rejected.
267
+ # org/client are NOT in that set -- reserved namespace
268
+ # names, not built-ins, registered like any other. They
269
+ # already exist tenant-wide at rank 1000/2000 (below) --
270
+ # pick a different specificityRank, and note a
271
+ # context-owned registration shadows the tenant-wide
272
+ # one for this context's own callers.
273
+ specificityRank: 500 # 0..1_000_000, unique among this blueprint's namespaces
274
+ entityBacked: true # optional
275
+ membershipRecordType: thing # MUST be a typeName this blueprint declares in
276
+ # schemas: above — membership can only resolve
277
+ # over a record type the blueprint itself ships,
278
+ # never one that merely already exists in the
279
+ # target context. membershipTargetField required
280
+ # alongside it.
281
+ membershipTargetField: userId
282
+ membershipLevelField: level # optional; membershipLevels required alongside it
283
+ membershipLevels: [admin, viewer]
284
+ # tenantWide: true # optional, default false. Requests registration visible to
285
+ # # EVERY context in the account, not just this blueprint's own —
286
+ # # see below.
287
+ # Registered in the SAME bootstrap-token phase as issuers, before schemas.
288
+ # Owned by this blueprint's own contextId (above) by default. `tenantWide: true` opts
289
+ # 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
291
+ # it without the explicit `--allow-tenant-wide-namespaces` flag — a blueprint cannot make
292
+ # this happen by itself. Registration is create-once, NOT idempotent server-side: a
293
+ # re-apply matching every declared field converges silently, but one that disagrees
294
+ # with what's already registered fails the apply rather than silently overwriting it —
295
+ # for a tenant-wide namespace this includes a collision with a DIFFERENT blueprint's
296
+ # (or a manual) registration of the same name, which always fails rather than being
297
+ # silently adopted.
298
+
299
+ identities:
300
+ team: # local name — referenced elsewhere as ${{ identities.team }}
301
+ kind: org # 'user', or an entity namespace (org/client/one you registered)
302
+ externalId: acme-team # your stable id; resolution is idempotent by this value
303
+ displayName: Acme Team # optional (users: folded into payload.displayName instead —
304
+ # a user has no first-class `name` field)
305
+ metadata: { plan: pro } # optional JSON object, merged into the principal's payload
306
+ # A declared name must match letters/digits/underscore, not starting with a
307
+ # digit (demoOrg, not demo-org) — the same grammar '${{ identities.<name> }}'
308
+ # itself can match. A name outside it is rejected as a parse error at
309
+ # declare time (validate/plan), not silently accepted. Every declared
310
+ # identity is ensure-exist'd (idempotent by externalId), whether or not
311
+ # anything below references it, in its OWN creds-bearing pass — under the
312
+ # bootstrap credential, AFTER the app-context, service principal, and any
313
+ # namespaces: above are registered, but BEFORE schemas/accessProfile/roles
314
+ # (not in BLUEPRINT_FIELD_PHASES for that reason). This ordering matters
315
+ # for a non-'user' kind: an 'org'/'client'/registered-namespace identity is
316
+ # created IN that namespace, so it can only succeed once the namespace
317
+ # exists — declare it in namespaces: above (or ensure it's already
318
+ # registered) before referencing it here with a non-'user' kind.
319
+ # Reference the resolved principal id anywhere one is valid — a schema's
320
+ # userId/scopes, an accessProfile/role dataScope or identityOverrides,
321
+ # seed-record ownership — e.g.:
322
+ # identityOverrides: { "scope:org": "${{ identities.team }}" }
323
+ # Only the WHOLE id can be referenced (no '${{ identities.team.externalId }}'
324
+ # property access), and every reference must name an identity declared here
325
+ # — both checked OFFLINE at validate/plan, never deferred to a live apply.
183
326
  ```
184
327
 
185
328
  YAML or JSON are both accepted (chosen by extension). The bundled library is
@@ -308,6 +451,19 @@ wildcard (`records:*`). Anything else — control-plane resources or the bare
308
451
  *why* it's safe to apply a blueprint you didn't write: the binary is the trust
309
452
  boundary, not the file.
310
453
 
454
+ ### Capabilities (`granted_capabilities`) — a separate, equally-gated dimension
455
+
456
+ An `accessProfile` or role clause may also declare `capabilities: [...]` —
457
+ named platform effects (like creating or removing an identity in your tenant)
458
+ 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`,
463
+ `plan`, `blueprint-test` and `bootstrap` all report a rejected capability the
464
+ same way they report a rejected scope: nothing mints, and the CLI exits
465
+ non-zero.
466
+
311
467
  ### The idempotency contract
312
468
 
313
469
  Re-applying a blueprint **converges, never duplicates**, because everything has
@@ -323,6 +479,21 @@ but fails on every re-apply with an "already exists" error instead of the
323
479
  existing record. Request `:r` alongside `:c` on every resource your blueprint
324
480
  creates (the bundled blueprints all do this — see the example above).
325
481
 
482
+ **One qualification to "converges, never duplicates": a NEW key mint bound to
483
+ a pre-existing service principal is not silent.** If `servicePrincipal.externalId`
484
+ resolves to an identity that already existed in the tenant before this run —
485
+ ordinarily true on a routine same-machine re-apply, since the earlier run's
486
+ local key record already names that same principal — `bootstrap` proceeds as
487
+ usual. But the FIRST time a new machine (no local key record for the blueprint's
488
+ `contextId`) applies a blueprint whose service principal already exists, it
489
+ refuses by default and asks for confirmation: interactively, a y/N prompt;
490
+ non-interactively (`--yes`, or no TTY), it requires `--confirm-existing-principal`
491
+ and otherwise exits with an error rather than minting silently. This is
492
+ deliberate — the platform cannot tell an intentional second-machine setup apart
493
+ from a blueprint's `externalId` colliding with an unrelated identity, so it asks
494
+ rather than guessing either way. `--rotate` is exempt only when the local
495
+ record already names the same principal being rotated.
496
+
326
497
  ## Distribution
327
498
 
328
499
  - **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.19.0 — 2026-08-27
7
+
8
+ ### Added
9
+
10
+ - **`vectros bootstrap`/`blueprint apply` mint a blueprint's own AccessProfile
11
+ from `accessProfile.roleIds`** (needs `@vectros-ai/blueprints` ≥ 0.16.0) —
12
+ the platform's additive role composition, wired end to end. Roles are
13
+ created BEFORE the AccessProfile mint (reordered from the prior sequence,
14
+ where nothing depended on the order), so a `roleIds` reference always
15
+ resolves on first apply. `vectros blueprint plan` previews the composed
16
+ form (`profile via role(s) [...]`), and the scope gate / footgun lints /
17
+ `blueprint-test` harness assertions all read the composed action set —
18
+ every referenced role's own clauses, concatenated in order — for a
19
+ `roleIds` profile instead of the (now-absent) inline `allowedActions`.
20
+ - **`vectros access grant --role` and `vectros join --role` are now
21
+ repeatable — 2+ roles compose additively**, sending the platform's
22
+ `roleIds` (plural) instead of the deprecated singular `roleId`. `access
23
+ get`/`access list` render the composed role list; `access explain` now
24
+ fetches and concatenates every referenced role's clauses instead of only
25
+ the first (a profile composing 2+ roles previously rendered as "no
26
+ clauses" — the singular `roleId` the old code followed is absent once a
27
+ profile composes more than one). A single `--role` still round-trips
28
+ through the deprecated singular field on an older server response.
29
+ - **An already-registered issuer now actually reconciles on a blueprint
30
+ re-apply** (needs `@vectros-ai/sdk` ≥ 0.41.0 for the
31
+ platform's `PUT /v1/auth/issuers/{issuerId}`) — `subClaim`/
32
+ `emailClaim`/`userinfoUri`/`selfSignupPolicies` now apply on re-apply, the
33
+ same create-then-reconcile shape the AccessProfile mint already used. The
34
+ trust-anchor/routing-pin fields (`issuer`/`jwksUri`/`audience`/`contextId`)
35
+ remain immutable via that route (rotating one still needs delete +
36
+ re-register), and `status` is reconcilable on the platform endpoint but has
37
+ no field in `BlueprintIssuerSchema` to author — `vectros blueprint plan`'s
38
+ legend and the apply-outcome summary both say so explicitly rather than
39
+ overclaiming full reconciliation.
40
+ **Behavior change:** editing a trust-anchor field (`issuer`/`jwksUri`/
41
+ `audience`/`contextId`) on an already-registered issuer and re-running
42
+ `bootstrap` now FAILS the whole run with the platform's 400, instead of
43
+ the prior silent no-op (the edit was simply never applied). This is the
44
+ correct direction — the loader stopped lying about it — but it is a
45
+ disclosed behavior change: a blueprint whose issuer trust anchor
46
+ drifted from its live registration will now block on re-apply where it
47
+ previously succeeded quietly.
48
+ - **A blueprint can now declare `issuers[].userinfoUri`** (needs
49
+ `@vectros-ai/sdk` ≥ 0.41.0, `@vectros-ai/blueprints` ≥ 0.16.0) — the IdP's
50
+ OIDC userinfo endpoint, used as a fallback email-resolution source when a
51
+ presented access token doesn't carry the configured `emailClaim` directly.
52
+ Lets a blueprint declare this server-side fallback instead of requiring a
53
+ manual `PUT /v1/auth/issuers/{issuerId}` outside blueprint authoring.
54
+ `vectros issuers get` now renders it alongside the other issuer fields.
55
+ - **`vectros identity invite --context <c> --role <roleId> --email <e>`** —
56
+ onboards a person into a context (`POST /v1/users/invite`) without a
57
+ hand-crafted `curl` call. `--role` resolves directly to
58
+ `accessProfile.roleId` (a Role's id is already its own user-facing
59
+ identifier — no separate name lookup). `--accept-url` is required unless
60
+ `--no-send-email` is given, in which case the response's raw
61
+ `inviteToken`/`acceptLink` let you deliver the invitation yourself.
62
+ Authorizes exactly like `access grant`/`role create` — an ordinary
63
+ context-pinned token minted from your `vectros login` session, not a root
64
+ key. No separate setup needed.
65
+
66
+ ### Fixed
67
+
68
+ - **`createRole` (the loader's role mint) now sends `upsert: true`** — without
69
+ it, an already-existing role's `scopes` were silently left unchanged on a
70
+ blueprint re-apply, so editing a role's `allowedActions`/`dataScope` and
71
+ re-running `bootstrap` never actually took effect, contradicting
72
+ `LoaderResult.roleActions`' own documented "reconciled means updated to
73
+ match" contract (the same false-convergence class this release fixes for
74
+ issuers). Pre-existing, caught in review while building `roleIds`
75
+ composition — now more consequential than before, since a `roleIds`-
76
+ composed AccessProfile ties the blueprint's own automatic service
77
+ credential to a role's live (previously possibly-stale) scope.
78
+ - **The bootstrap loader's scope gate now admits an unqualified `profiles:c`
79
+ when the same clause also declares `capabilities: [member-lifecycle]`** —
80
+ the platform's own documented pattern for a self-signup/admin role that
81
+ invites without raw tenant-wide `users:*` could not previously be
82
+ authored through `vectros blueprint apply`/`validate` at all: the gate's
83
+ action check and its capability check ran fully independently, so a bare
84
+ `profiles:c` always rejected regardless of what capabilities accompanied
85
+ it. The exception is narrow and additive — bare form only (a literal
86
+ qualifier like `profiles:c:usr_<id>` is still rejected unconditionally,
87
+ since `member-lifecycle`'s own elevation requires the fully unqualified
88
+ grant and does nothing to bound a literal-principal risk), same clause
89
+ only (a sibling clause's capability grants no benefit), every
90
+ pre-existing call site unaffected (the new parameter is optional).
91
+ - **`vectros blueprint validate`/`bootstrap`/`apply` no longer reject `${{ member.scope.<namespace> }}`
92
+ (and its `:<level>` form) as an unrecognized placeholder.** This is a real, shipped, server-side
93
+ mechanism — resolved per request from `org_membership`-shaped records, with no dependency on a
94
+ separate, unimplemented token-select step that `${{ self.scope.<ns> }}` needs — the CLI's local
95
+ lint simply hadn't been updated to recognize it, so an otherwise-correct blueprint authored
96
+ against it failed `validate` outright with a false `datascope-unknown-placeholder` error, even
97
+ though the platform itself would have accepted it. Found building a real org-membership-based
98
+ blueprint: a role's `${{ self.scope.org }}` dataScope silently granted nothing (an unresolved
99
+ self-placeholder matches nothing, not everything) because nothing populates that identity value
100
+ by default — switching to `${{ member.scope.org }}` is the actual fix for an org_membership-based
101
+ role, and the lint was wrongly in the way of it.
102
+ - **`vectros access explain` now reports `${{ member.scope.<ns>[:<level>] }}` as undecidable** instead
103
+ of silently mismodeling the clause as an opaque literal string — the clause normalizer had no MEMBER
104
+ case at all before this. Same treatment `${{ under.self.* }}` already gets, via `undecidableDims`
105
+ rather than a false match or a silent denial.
106
+
107
+ ### Changed
108
+
109
+ - **`@vectros-ai/sdk` repinned to the latest available `0.41.0` build** —
110
+ picks up the additive role composition's `roleIds` on
111
+ `AccessProfileRequest`/`AccessProfileResponse` and the issuer-update
112
+ surface (`IssuerUpdateRequest`, `client.auth.updateIssuer`). No other wire
113
+ shape changed since the prior pin (verified against the published
114
+ tarballs, not just source ancestry).
115
+
116
+ ## 0.18.0
117
+
118
+ ### Added
119
+
120
+ - **`vectros blueprint plan` now renders the fully-expanded, effective
121
+ per-clause `dataScope` for every declared role** (needs
122
+ `@vectros-ai/blueprints` ≥ 0.15.0) — not just the role's flattened action
123
+ set. This is the review-legibility mitigation the `fragments:`/
124
+ `dataScopeRef` sugar requires: a clause referencing a fragment has already
125
+ been expanded to a literal `dataScope` by the time the CLI sees it, so the
126
+ plan shows exactly what will be granted, never the DSL indirection that
127
+ produced it. Two clauses sharing an action set can legitimately carry
128
+ different `dataScope`, so this is shown per clause, not flattened across
129
+ the role the way the action set is.
130
+ - **A blueprint's `roleAssumable` map is now wired through
131
+ apply** — `vectros bootstrap`/`blueprint apply` passes
132
+ `roleAssumable.<roleId>` straight through to that role's `createRole`
133
+ call as `assumable`, unchanged. `vectros blueprint plan` discloses the
134
+ grant per role (a security-relevant fact the operator approving the plan
135
+ needs to see, same reasoning as the existing capability disclosure), and
136
+ `vectros role get`'s human view now shows a role's `assumable` grant
137
+ (`--json` already did — it prints the SDK response verbatim). Needs
138
+ `@vectros-ai/blueprints` ≥ 0.15.0 and `@vectros-ai/sdk` ≥ 0.41.0 (the
139
+ first generated SDK build carrying `Role.assumable`).
140
+
141
+ ## 0.17.0
142
+ ### Added
143
+
144
+ - **`vectros issuers list --context <ctx>` and `vectros issuers get <issuerId>
145
+ --context <ctx>`** — read a tenant's registered token-exchange issuers
146
+ without hand-minting a bootstrap token first. `GET`/`LIST /v1/auth/issuers`
147
+ require provisioning capability AND are context-confined for any non-root
148
+ caller, so `--context` is a required flag, not a default: a provisioning
149
+ token minted with no context pin resolves to the platform's `default`
150
+ context and won't see an issuer registered under any other one. Both verbs
151
+ now mint a single st_* carrying provisioning capability PINNED to
152
+ `--context` — a shape the CLI's auth seam couldn't produce before this.
153
+ - **A blueprint's `namespaces[]` entry can declare `tenantWide: true`** (needs
154
+ `@vectros-ai/blueprints` ≥ 0.14.0), requesting the platform's tenant-wide
155
+ registration form instead of the blueprint's own context. `bootstrap`/`apply`
156
+ now REFUSE such a blueprint before anything provisions — naming the
157
+ namespace(s) — unless invoked with the new `--allow-tenant-wide-namespaces`
158
+ flag: a static, scriptable opt-in, deliberately not an interactive runtime
159
+ prompt. This is the human-deliberateness check only; whether the request
160
+ actually succeeds is still decided server-side by an OWNER-gated platform
161
+ capability the flag has no bearing on — a non-OWNER credential gets the
162
+ existing clear failure regardless of the flag. An ordinary blueprint (no
163
+ `tenantWide` namespace) is completely unaffected.
164
+ - **`vectros blueprint plan`'s namespace preview names a `tenantWide` namespace
165
+ explicitly** (`TENANT-WIDE — visible to every context`, in place of the usual
166
+ `owned by '<contextId>'`), and its legend gains a carve-out: unlike an
167
+ ordinary namespace registration (which converges silently on a matching
168
+ re-apply), a tenant-wide one never adopts an existing registration, even an
169
+ exact field-for-field match — a collision always fails.
170
+
171
+ ### Changed
172
+
173
+ - **A namespace-name collision on a `tenantWide` registration is always a hard
174
+ failure, never adopted.** The existing read-back-and-reconcile behavior for
175
+ an ordinary, context-owned namespace (a re-apply matching every declared
176
+ field converges silently) does NOT extend to a tenant-wide one: a tenant-wide
177
+ row is co-owned by no single blueprint, so a field-for-field match is not
178
+ evidence it's safe to treat as "this blueprint's" — it could just as easily
179
+ be a different blueprint's (or a manual) registration that happens to declare
180
+ the same shape. The platform's 400 propagates as-is on any collision.
181
+
182
+ ### Fixed
183
+
184
+ - **`bootstrap`/`apply` now register a blueprint's `namespaces[]` BEFORE
185
+ creating any identity it declares in `identities[]`.** Previously, apply-time
186
+ identity resolution ran before namespace registration, so a blueprint
187
+ declaring `identities:` in a namespace it ALSO registers via `namespaces:`
188
+ could never succeed — creating that identity requires the namespace to
189
+ already be registered, and the old ordering always raced the identity create
190
+ ahead of it. The app-context, service principal, and every declared
191
+ namespace are now fully provisioned first; identity resolution (and
192
+ everything downstream) runs after. This is a step-ordering change only — an
193
+ unaffected blueprint (no `identities:` referencing a namespace it also
194
+ registers) sees no behavior change, and no CLI flag or blueprint schema
195
+ changed.
196
+
197
+ ## 0.16.0
198
+
199
+ ### Added
200
+
201
+ - **A blueprint's `accessProfile` and role clauses can now declare
202
+ `capabilities: [...]`** — the platform's `granted_capabilities` field,
203
+ a grant dimension separate from `allowedActions` for named effects that
204
+ reach across a partition boundary (e.g. creating/removing an identity in
205
+ your tenant) which no `resource:ops` verb can express. Gated the same way
206
+ `allowedActions` is: only `member-lifecycle` is admitted, fail-closed,
207
+ every other name (including ones the platform's own registry recognizes)
208
+ is refused before anything mints. `vectros blueprint validate`/`plan` and
209
+ `bootstrap` all report a capability grant/rejection the same way they
210
+ already report a scope grant/rejection.
211
+
212
+ - **`bootstrap` now flags when a mint binds to a principal that already existed in your
213
+ tenant before this run**, and the outcome summary always reports whether the service
214
+ principal was newly created or reconciled. This surfaces the one collision shape the
215
+ platform itself can't tell apart from an intentional one (e.g. re-applying a blueprint
216
+ from a second machine): a blueprint's declared `servicePrincipal.externalId` matching an
217
+ existing, unrelated identity in your tenant. Interactively, you're prompted to confirm
218
+ before the key mints; non-interactively (`--yes`, or no TTY) the run refuses by default
219
+ and requires the new `--confirm-existing-principal` flag to proceed, so an unexpected
220
+ collision fails a CI run loudly instead of silently minting a credential bound to the
221
+ wrong identity. Never triggered by `--rotate` or an ordinary same-machine re-apply — both
222
+ are exempted as already-informed operator intent.
223
+
224
+ - **The bootstrap scope gate now admits `profiles:c:self`.** The platform's
225
+ `profiles:c`/`u`/`d` accept an optional qualifier confining WHICH principal
226
+ the grant may act on — a literal `usr_<id>`, or the bare `self` sentinel,
227
+ which the platform resolves from the caller's own bound identity, never
228
+ from anything a blueprint declares. A role clause (or the bootstrap
229
+ `accessProfile`) may now grant `profiles:c:self` to let a credential create
230
+ an access profile for itself. A bare, unqualified `profiles:c`, and a
231
+ literal `profiles:c:usr_<id>` naming another principal, still hard-reject —
232
+ only the platform-resolved `self` form is admitted.
233
+
234
+ - **`vectros blueprint validate`/`plan` now warns when a role or the
235
+ `accessProfile` clause has NO `dataScope` key at all on a read/update/delete
236
+ action against `records`/`documents`/`entities`.** The existing null-sentinel
237
+ warning already caught a *narrower* dataScope gap; this catches the more
238
+ dangerous case of no constraint whatsoever, which grants reach across every
239
+ owner in the tenant — not "my own org", whatever the author may have
240
+ intended. Advisory only (`exit 0`): a deliberately shared/tenant-wide
241
+ resource (declared with `dataScope: {}`) is unaffected.
242
+
243
+ - **Top-level `namespaces[]`** registers a blueprint's own namespaces via
244
+ `POST /v1/namespaces`, in the same bootstrap-token phase as `issuers`.
245
+ Every registration is unconditionally owned by the blueprint's own
246
+ context — there is no tenant-wide option, since the platform confines
247
+ namespace registration to the caller's own context unconditionally, even
248
+ for a `contextId: null` request. Registration is not idempotent
249
+ server-side; a re-apply matching the existing declaration converges
250
+ silently, one that disagrees throws rather than being swallowed.
251
+
252
+ ### Fixed
253
+
254
+ - **`key issue`/`list`/`get`/`revoke`/`rotate` can now target the test tenant
255
+ via `--tenant test`**, matching `bootstrap`/`join`/`context`. Previously
256
+ none of the five accepted it, so a key minted into the test tenant (e.g. by
257
+ `bootstrap --tenant test`) could be discovered via `key list --all-contexts`
258
+ (which spans both tenants) but never individually fetched, revoked, rotated,
259
+ or re-issued — there was no way to reach it at all. `--tenant` is refused in
260
+ combination with `--all-contexts`, which already spans both tenants on its
261
+ own.
262
+
263
+ - **`bootstrap` (and `blueprint-test`) now work for a blueprint whose `issuers:`
264
+ target a non-`default` app context** — previously every such blueprint failed
265
+ registration with an authorization error, even though the same blueprint's
266
+ schemas/roles/access profile provisioned fine. The bootstrap credential now
267
+ re-mints itself, pinned to the blueprint's own context, specifically to
268
+ register issuers — closing the gap left once the platform started requiring
269
+ an issuer registration to be confined to the context it targets. No blueprint
270
+ authoring change needed; existing `issuers:` entries behave the same, they
271
+ just no longer fail for any context other than `default`.
272
+
273
+ - **`blueprint-test` now also registers and reads back each declared `namespaces[]` entry**,
274
+ the same assert-readback proof `issuers`/schemas/roles already get. Its teardown is honest
275
+ about what it can and can't undo: a namespace registration is never deleted — namespace
276
+ deletion requires a root API key, which the harness never holds — and is reported as a
277
+ permanent skip rather than a silently-attempted step. Issuer teardown may now report a failed
278
+ step rather than a silent success, too: the platform refuses (`409`) to delete an issuer that
279
+ has ever completed a real token exchange during the run.
280
+
281
+ - **The blueprint format reference showed an `issuers:` entry that no longer validates.**
282
+ `AUTHORING.md`'s "the format, field by field" example declared one `contextId` for the
283
+ blueprint and a different one inside `issuers[0]`. As of `@vectros-ai/blueprints` 0.13.0
284
+ an issuer must target the blueprint's own app context, so copying that example produced a
285
+ blueprint `vectros blueprint validate` rejects. The example now matches, and the reference
286
+ states the constraint where the field is introduced rather than leaving you to discover it
287
+ from a validation error.
288
+
289
+ - **The bootstrap scope gate now admits `profiles:r`/`profiles:u`/`profiles:d`** —
290
+ read is disclosure-only and cannot widen anything; update/delete are bounded by
291
+ a platform-enforced check that a write to an AccessProfile can never grant more
292
+ scope than the requesting credential already holds, so they can only
293
+ redistribute or revoke access, never manufacture new reach. A bare, unqualified
294
+ `profiles:c` (creating a profile for an arbitrary, caller-chosen principal
295
+ name) and every `users:*` verb stay rejected — see the `profiles:c:self`
296
+ entry above for the one qualified exception. This unblocks any blueprint
297
+ role that reads or manages its own app-context's membership — previously
298
+ the gate refused the whole role outright the moment it named any
299
+ `profiles:*` scope.
300
+
301
+ - **`bootstrap --tenant test`, `join --tenant test`, and `key issue|rotate
302
+ --to-keyring` no longer silently activate over a live credential.** Storing
303
+ a freshly-minted (or re-joined) test-tenant key used to make it the
304
+ machine's active identity unconditionally, exactly like a live-tenant run —
305
+ flipping what `vectros keyring show --format raw` (and anything that
306
+ resolves through it with no explicit `--alias`, including the MCP server)
307
+ authenticates as, with no warning. `key issue|rotate --to-keyring` has no
308
+ `--tenant` flag of its own; it now classifies the tenant from the minted
309
+ secret's own prefix. All three commands now activate unless doing so would
310
+ displace a currently-active LIVE credential, in which case the key is
311
+ stored (or the re-join still counts as a successful join) without switching
312
+ to it, and the terminal says so explicitly — naming whether that was the
313
+ safety default or an explicit `--no-activate`, never the wrong reason.
314
+ `bootstrap`/`join` also take `--activate` to force activation over a live
315
+ credential explicitly; `key issue|rotate --to-keyring` has no such flag
316
+ today — run `vectros switch <alias>` afterward if you want that key active.
317
+
318
+ - **`keyring list`'s human-readable table now shows a `tenant` (test/live)
319
+ column and drops `env` (staging/production).** `env` is an internal-team
320
+ concern an external developer's CLI never surfaces; `tenant` is the
321
+ distinction every user actually holds two credentials side by side over, and
322
+ previously the only way to see it was to run `doctor` instead or hope the
323
+ alias happened to carry a `.test` suffix. `--json` output is UNCHANGED —
324
+ still metadata-only, no per-entry secret read, no `tenant` field — because
325
+ resolving every entry's secret from the OS credential store (what showing
326
+ tenant requires) is real per-invocation cost a script relying on `--json`'s
327
+ established speed should not silently start paying.
328
+
329
+ ### Changed
330
+
331
+ - **Internal:** the exact `@vectros-ai/blueprints` dependency pin was updated so a clean
332
+ `npm ci` resolves. No behaviour change.
333
+
334
+ ### Security
335
+
336
+ - **`@napi-rs/keyring` is now pinned to an exact version** (was `^1.3.0`). The
337
+ Linux/Windows fail-closed guarantee behind `keyring remove`/`prune` rests on
338
+ an upstream behavioural contract — a locked credential store must make
339
+ `findCredentials()` throw, not return an empty list — that this package does
340
+ not otherwise verify at install time. A future bump of this dependency should
341
+ be treated as a security review of that contract against the new release, not
342
+ a routine version update (see the comment at its enforcement site,
343
+ `os-keychain.ts`, for exactly what would break).
344
+
6
345
  ## 0.15.0
7
346
 
8
347
  ### Added