@stigmer/server 3.15.2 → 3.15.3-dev.20260914121148

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.
Files changed (217) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +103 -9
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/iampolicy/access-lists.d.ts +27 -0
  5. package/dist/domain/iampolicy/access-lists.d.ts.map +1 -0
  6. package/dist/domain/iampolicy/access-lists.js +124 -0
  7. package/dist/domain/iampolicy/access-lists.js.map +1 -0
  8. package/dist/domain/iampolicy/constants.d.ts +135 -0
  9. package/dist/domain/iampolicy/constants.d.ts.map +1 -0
  10. package/dist/domain/iampolicy/constants.js +203 -0
  11. package/dist/domain/iampolicy/constants.js.map +1 -0
  12. package/dist/domain/iampolicy/controller.d.ts +29 -0
  13. package/dist/domain/iampolicy/controller.d.ts.map +1 -0
  14. package/dist/domain/iampolicy/controller.js +451 -0
  15. package/dist/domain/iampolicy/controller.js.map +1 -0
  16. package/dist/domain/iampolicy/display-resolver.d.ts +6 -0
  17. package/dist/domain/iampolicy/display-resolver.d.ts.map +1 -0
  18. package/dist/domain/iampolicy/display-resolver.js +90 -0
  19. package/dist/domain/iampolicy/display-resolver.js.map +1 -0
  20. package/dist/domain/iampolicy/grant-path.d.ts +42 -0
  21. package/dist/domain/iampolicy/grant-path.d.ts.map +1 -0
  22. package/dist/domain/iampolicy/grant-path.js +183 -0
  23. package/dist/domain/iampolicy/grant-path.js.map +1 -0
  24. package/dist/domain/iampolicy/grant-scope.d.ts +4 -0
  25. package/dist/domain/iampolicy/grant-scope.d.ts.map +1 -0
  26. package/dist/domain/iampolicy/grant-scope.js +34 -0
  27. package/dist/domain/iampolicy/grant-scope.js.map +1 -0
  28. package/dist/domain/iampolicy/membership.d.ts +111 -0
  29. package/dist/domain/iampolicy/membership.d.ts.map +1 -0
  30. package/dist/domain/iampolicy/membership.js +152 -0
  31. package/dist/domain/iampolicy/membership.js.map +1 -0
  32. package/dist/domain/iampolicy/permissions.d.ts +19 -0
  33. package/dist/domain/iampolicy/permissions.d.ts.map +1 -0
  34. package/dist/domain/iampolicy/permissions.js +27 -0
  35. package/dist/domain/iampolicy/permissions.js.map +1 -0
  36. package/dist/domain/iampolicy/resource-store.d.ts +4 -0
  37. package/dist/domain/iampolicy/resource-store.d.ts.map +1 -0
  38. package/dist/domain/iampolicy/resource-store.js +135 -0
  39. package/dist/domain/iampolicy/resource-store.js.map +1 -0
  40. package/dist/domain/iampolicy/role-lifecycle.d.ts +9 -0
  41. package/dist/domain/iampolicy/role-lifecycle.d.ts.map +1 -0
  42. package/dist/domain/iampolicy/role-lifecycle.js +106 -0
  43. package/dist/domain/iampolicy/role-lifecycle.js.map +1 -0
  44. package/dist/domain/iampolicy/roles.d.ts +11 -0
  45. package/dist/domain/iampolicy/roles.d.ts.map +1 -0
  46. package/dist/domain/iampolicy/roles.js +78 -0
  47. package/dist/domain/iampolicy/roles.js.map +1 -0
  48. package/dist/domain/iampolicy/specs.d.ts +7 -0
  49. package/dist/domain/iampolicy/specs.d.ts.map +1 -0
  50. package/dist/domain/iampolicy/specs.js +45 -0
  51. package/dist/domain/iampolicy/specs.js.map +1 -0
  52. package/dist/domain/iampolicy/steps.d.ts +18 -0
  53. package/dist/domain/iampolicy/steps.d.ts.map +1 -0
  54. package/dist/domain/iampolicy/steps.js +117 -0
  55. package/dist/domain/iampolicy/steps.js.map +1 -0
  56. package/dist/domain/iampolicy/store-contract.d.ts +8 -0
  57. package/dist/domain/iampolicy/store-contract.d.ts.map +1 -0
  58. package/dist/domain/iampolicy/store-contract.js +258 -0
  59. package/dist/domain/iampolicy/store-contract.js.map +1 -0
  60. package/dist/domain/iampolicy/store.d.ts +78 -0
  61. package/dist/domain/iampolicy/store.d.ts.map +1 -0
  62. package/dist/domain/iampolicy/store.js +13 -0
  63. package/dist/domain/iampolicy/store.js.map +1 -0
  64. package/dist/domain/iampolicy/wire-refusals.d.ts +25 -0
  65. package/dist/domain/iampolicy/wire-refusals.d.ts.map +1 -0
  66. package/dist/domain/iampolicy/wire-refusals.js +76 -0
  67. package/dist/domain/iampolicy/wire-refusals.js.map +1 -0
  68. package/dist/domain/identityaccount/constants.d.ts +33 -0
  69. package/dist/domain/identityaccount/constants.d.ts.map +1 -1
  70. package/dist/domain/identityaccount/constants.js +2 -48
  71. package/dist/domain/identityaccount/constants.js.map +1 -1
  72. package/dist/domain/identityaccount/controller.d.ts +8 -1
  73. package/dist/domain/identityaccount/controller.d.ts.map +1 -1
  74. package/dist/domain/identityaccount/controller.js +54 -29
  75. package/dist/domain/identityaccount/controller.js.map +1 -1
  76. package/dist/domain/identityaccount/operator.d.ts.map +1 -1
  77. package/dist/domain/identityaccount/operator.js +2 -4
  78. package/dist/domain/identityaccount/operator.js.map +1 -1
  79. package/dist/domain/identityaccount/provisioning.d.ts +20 -1
  80. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
  81. package/dist/domain/identityaccount/provisioning.js +17 -3
  82. package/dist/domain/identityaccount/provisioning.js.map +1 -1
  83. package/dist/domain/identityaccount/resolve.d.ts +23 -0
  84. package/dist/domain/identityaccount/resolve.d.ts.map +1 -1
  85. package/dist/domain/identityaccount/resolve.js +17 -0
  86. package/dist/domain/identityaccount/resolve.js.map +1 -1
  87. package/dist/domain/identityaccount/store-contract.d.ts +4 -18
  88. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
  89. package/dist/domain/identityaccount/store-contract.js +12 -47
  90. package/dist/domain/identityaccount/store-contract.js.map +1 -1
  91. package/dist/extensions/authorization-queries.d.ts +81 -0
  92. package/dist/extensions/authorization-queries.d.ts.map +1 -0
  93. package/dist/extensions/authorization-queries.js +2 -0
  94. package/dist/extensions/authorization-queries.js.map +1 -0
  95. package/dist/extensions/drivers.d.ts +49 -2
  96. package/dist/extensions/drivers.d.ts.map +1 -1
  97. package/dist/extensions/identity.d.ts +14 -0
  98. package/dist/extensions/identity.d.ts.map +1 -1
  99. package/dist/extensions/identity.js +18 -1
  100. package/dist/extensions/identity.js.map +1 -1
  101. package/dist/extensions/list-read-scope.d.ts +41 -4
  102. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  103. package/dist/extensions/list-read-scope.js +44 -7
  104. package/dist/extensions/list-read-scope.js.map +1 -1
  105. package/dist/extensions/policy-grant-scope.d.ts +50 -0
  106. package/dist/extensions/policy-grant-scope.d.ts.map +1 -0
  107. package/dist/extensions/policy-grant-scope.js +2 -0
  108. package/dist/extensions/policy-grant-scope.js.map +1 -0
  109. package/dist/extensions/registry.d.ts +23 -1
  110. package/dist/extensions/registry.d.ts.map +1 -1
  111. package/dist/extensions/registry.js +30 -0
  112. package/dist/extensions/registry.js.map +1 -1
  113. package/dist/extensions/resource-authorization.d.ts +63 -0
  114. package/dist/extensions/resource-authorization.d.ts.map +1 -1
  115. package/dist/index.d.ts +7 -1
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +2 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/pipeline/apiresource-meta.d.ts +77 -1
  120. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  121. package/dist/pipeline/apiresource-meta.js +183 -1
  122. package/dist/pipeline/apiresource-meta.js.map +1 -1
  123. package/dist/pipeline/interceptors/auth.d.ts +12 -0
  124. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  125. package/dist/pipeline/interceptors/auth.js +13 -1
  126. package/dist/pipeline/interceptors/auth.js.map +1 -1
  127. package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
  128. package/dist/pipeline/steps/authorization-tuples.js +7 -26
  129. package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
  130. package/dist/pipeline/steps/authorize.d.ts +20 -5
  131. package/dist/pipeline/steps/authorize.d.ts.map +1 -1
  132. package/dist/pipeline/steps/authorize.js +37 -17
  133. package/dist/pipeline/steps/authorize.js.map +1 -1
  134. package/dist/pipeline/steps/defaults.d.ts +10 -0
  135. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  136. package/dist/pipeline/steps/defaults.js +39 -0
  137. package/dist/pipeline/steps/defaults.js.map +1 -1
  138. package/dist/pipeline/steps/shapes.d.ts +18 -0
  139. package/dist/pipeline/steps/shapes.d.ts.map +1 -1
  140. package/dist/pipeline/steps/shapes.js +29 -0
  141. package/dist/pipeline/steps/shapes.js.map +1 -1
  142. package/dist/store/port-contract.d.ts +61 -0
  143. package/dist/store/port-contract.d.ts.map +1 -0
  144. package/dist/store/port-contract.js +67 -0
  145. package/dist/store/port-contract.js.map +1 -0
  146. package/package.json +4 -4
  147. package/src/boot/compose.ts +107 -10
  148. package/src/domain/iampolicy/__tests__/access-lists.test.ts +238 -0
  149. package/src/domain/iampolicy/__tests__/constants.test.ts +388 -0
  150. package/src/domain/iampolicy/__tests__/controller.test.ts +960 -0
  151. package/src/domain/iampolicy/__tests__/display-resolver.test.ts +138 -0
  152. package/src/domain/iampolicy/__tests__/grant-path.test.ts +474 -0
  153. package/src/domain/iampolicy/__tests__/grant-scope.test.ts +61 -0
  154. package/src/domain/iampolicy/__tests__/iampolicy.test.ts +532 -0
  155. package/src/domain/iampolicy/__tests__/membership.test.ts +447 -0
  156. package/src/domain/iampolicy/__tests__/permissions.test.ts +41 -0
  157. package/src/domain/iampolicy/__tests__/resource-store.test.ts +165 -0
  158. package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +361 -0
  159. package/src/domain/iampolicy/__tests__/roles.test.ts +53 -0
  160. package/src/domain/iampolicy/__tests__/specs.test.ts +41 -0
  161. package/src/domain/iampolicy/__tests__/steps.test.ts +278 -0
  162. package/src/domain/iampolicy/__tests__/support.ts +195 -0
  163. package/src/domain/iampolicy/__tests__/wire-refusals.test.ts +117 -0
  164. package/src/domain/iampolicy/access-lists.ts +191 -0
  165. package/src/domain/iampolicy/constants.ts +243 -0
  166. package/src/domain/iampolicy/controller.ts +798 -0
  167. package/src/domain/iampolicy/display-resolver.ts +123 -0
  168. package/src/domain/iampolicy/grant-path.ts +270 -0
  169. package/src/domain/iampolicy/grant-scope.ts +36 -0
  170. package/src/domain/iampolicy/membership.ts +298 -0
  171. package/src/domain/iampolicy/permissions.ts +35 -0
  172. package/src/domain/iampolicy/resource-store.ts +186 -0
  173. package/src/domain/iampolicy/role-lifecycle.ts +132 -0
  174. package/src/domain/iampolicy/roles.ts +95 -0
  175. package/src/domain/iampolicy/specs.ts +53 -0
  176. package/src/domain/iampolicy/steps.ts +178 -0
  177. package/src/domain/iampolicy/store-contract.ts +450 -0
  178. package/src/domain/iampolicy/store.ts +103 -0
  179. package/src/domain/iampolicy/wire-refusals.ts +110 -0
  180. package/src/domain/identityaccount/__tests__/provisioning.test.ts +19 -7
  181. package/src/domain/identityaccount/__tests__/resolve.test.ts +113 -2
  182. package/src/domain/identityaccount/constants.ts +16 -31
  183. package/src/domain/identityaccount/controller.ts +65 -35
  184. package/src/domain/identityaccount/operator.ts +5 -5
  185. package/src/domain/identityaccount/provisioning.ts +43 -5
  186. package/src/domain/identityaccount/resolve.ts +43 -0
  187. package/src/domain/identityaccount/store-contract.ts +22 -70
  188. package/src/extensions/__tests__/composed-support.ts +87 -0
  189. package/src/extensions/__tests__/iam-policy-composed.test.ts +373 -0
  190. package/src/extensions/__tests__/iam-policy-points.test.ts +169 -0
  191. package/src/extensions/__tests__/identity-account-composed.test.ts +12 -78
  192. package/src/extensions/__tests__/identity.test.ts +62 -0
  193. package/src/extensions/__tests__/list-read-scope.test.ts +138 -0
  194. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  195. package/src/extensions/authorization-queries.ts +97 -0
  196. package/src/extensions/drivers.ts +49 -2
  197. package/src/extensions/identity.ts +21 -0
  198. package/src/extensions/list-read-scope.ts +87 -9
  199. package/src/extensions/policy-grant-scope.ts +50 -0
  200. package/src/extensions/registry.ts +62 -1
  201. package/src/extensions/resource-authorization.ts +65 -0
  202. package/src/index.ts +20 -0
  203. package/src/pipeline/__tests__/grantable-roles-for.test.ts +87 -0
  204. package/src/pipeline/__tests__/inherited-authorization-parent.test.ts +51 -0
  205. package/src/pipeline/__tests__/kind-by-enum-name.test.ts +107 -0
  206. package/src/pipeline/__tests__/kind-served-by-edition.test.ts +107 -0
  207. package/src/pipeline/apiresource-meta.ts +218 -1
  208. package/src/pipeline/interceptors/auth.ts +14 -1
  209. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +73 -3
  210. package/src/pipeline/steps/__tests__/authorize.test.ts +134 -9
  211. package/src/pipeline/steps/__tests__/derived-id.test.ts +49 -0
  212. package/src/pipeline/steps/authorization-tuples.ts +7 -28
  213. package/src/pipeline/steps/authorize.ts +46 -21
  214. package/src/pipeline/steps/defaults.ts +42 -0
  215. package/src/pipeline/steps/shapes.ts +31 -0
  216. package/src/store/__tests__/port-contract.test.ts +123 -0
  217. package/src/store/port-contract.ts +102 -0
@@ -26,6 +26,11 @@
26
26
  * - identity-account store + identity federation (the first domain
27
27
  * whose persistence is a PORT, and the four federated RPC arms only
28
28
  * one edition serves) — landed with 20260911.11, gate ruling Q-IA-9
29
+ * - IamPolicy store + policy grant scope + authorization queries (the
30
+ * IamPolicy domain's row half served in every edition; the store
31
+ * port, which kinds an edition grants on, and the tuple-half query
32
+ * engine only an authorization backend can answer) — landed with
33
+ * 20260913.01, gate ruling Q-OR-10
29
34
  *
30
35
  * Merge rules (enforced by resolveExtensions, DD-006 §2b): the two
31
36
  * provider kinds are single-instance points — a second declaring unit is
@@ -40,14 +45,17 @@
40
45
  import type { ArtifactStorageDriverFactory } from "../artifactstorage/artifact-storage.js";
41
46
  import type { ChannelRuntime } from "../domain/agentchannel/channel-runtime.js";
42
47
  import type { IdentityAccountStore } from "../domain/identityaccount/store.js";
48
+ import type { IamPolicyStore } from "../domain/iampolicy/store.js";
43
49
  import type { SecretCodec } from "../encryption/codec.js";
44
50
  import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
45
51
  import type { VisitorErrorPolicy } from "../pipeline/interceptors/error-boundary.js";
46
52
  import type { RunnerCredentialProvider } from "../runnerauth/runner-credential-provider.js";
47
53
  import type { SandboxProvisionerFactory } from "../sandbox/provisioner.js";
54
+ import type { AuthorizationQueryEngine } from "./authorization-queries.js";
48
55
  import type { IdentityFederation } from "./identity-federation.js";
49
56
  import type { ListReadScope } from "./list-read-scope.js";
50
57
  import type { OrganizationDirectory } from "./organization-directory.js";
58
+ import type { PolicyGrantScope } from "./policy-grant-scope.js";
51
59
  import type { ResourceAuthorizationLifecycle } from "./resource-authorization.js";
52
60
  import type { ScheduleFireCallerMint } from "./schedule-fire-caller.js";
53
61
 
@@ -90,8 +98,13 @@ export interface ExtensionDrivers {
90
98
  * The resource-authorization lifecycle seam (C2, ruling Q2;
91
99
  * single-instance point). When composed, the three shared tuple steps
92
100
  * (CreateAuthorizationTuples / CleanupIamPolicies /
93
- * UpdateVisibilityTuples) deliver resolved events to it; when absent,
94
- * those steps no-op — OSS behavior byte-identical.
101
+ * UpdateVisibilityTuples) deliver resolved events to it, and the
102
+ * IamPolicy grant path delivers the two policy hooks. Corrected
103
+ * 2026-09-13 (20260913.01 Q-OR-6): when absent, the steps no-op, but
104
+ * open source is no longer record-less — the composition root installs
105
+ * the built-in role lifecycle (domain/iampolicy/role-lifecycle.ts, the
106
+ * entry's slice 4), which writes IamPolicy rows, whenever no extension
107
+ * registers an Authorizer (resource-authorization.ts header).
95
108
  */
96
109
  readonly resourceAuthorizationLifecycle?: ResourceAuthorizationLifecycle;
97
110
  /**
@@ -169,4 +182,38 @@ export interface ExtensionDrivers {
169
182
  * edition reason — the organizationDirectory absent-method shape.
170
183
  */
171
184
  readonly identityFederation?: IdentityFederation;
185
+ /**
186
+ * The IamPolicy store driver (20260913.01, Q-OR-10; single-instance
187
+ * point). The IamPolicy domain's persistence is a PORT like the
188
+ * identity-account domain's: when composed, every row the one grant
189
+ * path writes, deletes or reads — user grants, the structural
190
+ * bootstrap lanes, the access lists and counts — goes through this
191
+ * driver (the cloud serves the domain over its own `cloud.iam_policy`
192
+ * table this way, legacy random ids included). When absent, the OSS
193
+ * adapter over the generic Store installs at the compose.ts
194
+ * consumption site — OSS behavior byte-identical.
195
+ */
196
+ readonly iamPolicyStore?: IamPolicyStore;
197
+ /**
198
+ * The policy grant scope (20260913.01, Q-OR-3; single-instance point):
199
+ * which kinds a user may grant a role on in this edition, with which
200
+ * roles. Narrows the proto's `grantable_roles`, total over the enum,
201
+ * synchronous (policy-grant-scope.ts carries the reasons). When
202
+ * composed, ValidateGrantableRole and checkMyPermission's
203
+ * `can_grant_access` arm read it; when absent, open source's
204
+ * organization-only default installs at the compose.ts consumption
205
+ * site — the organization grants its four proto roles, nothing else
206
+ * grants anything.
207
+ */
208
+ readonly policyGrantScope?: PolicyGrantScope;
209
+ /**
210
+ * The authorization-query engine (20260913.01, Q-OR-8; single-instance
211
+ * point): the tuple-half questions — checkAuthorization, the two
212
+ * listAuthorized*Ids, a checkMyPermission with contextual policies —
213
+ * only an authorization backend can answer over its own graph. When
214
+ * composed, the IamPolicy query controller dispatches to it after its
215
+ * own trust checks; when absent, those RPC arms refuse UNIMPLEMENTED
216
+ * with the edition reason — the identityFederation absent shape.
217
+ */
218
+ readonly authorizationQueries?: AuthorizationQueryEngine;
172
219
  }
@@ -97,3 +97,24 @@ export interface IdentityVerifier {
97
97
  */
98
98
  verify(token: string): Promise<CallerIdentity | null>;
99
99
  }
100
+
101
+ /**
102
+ * Whether `caller` is one of the platform's own pipelines — the admission
103
+ * rule of the RPCs a person must never reach directly, stated ONCE
104
+ * (20260911.11 A7 for the identity-account `create` RPC; 20260913.01
105
+ * Q-OR-7 and Q-S5-10 for the three IamPolicy system RPCs): a `machine`
106
+ * account (the platform's service accounts), the `internal` class (the
107
+ * server acting as itself over the in-process transport) or ANY identity
108
+ * that entered through that transport (a propagated user the server is
109
+ * composing a request for). A wire `user`, a `runner` and a composition's
110
+ * own classes are refused by the RPC that reads this — under the
111
+ * permissive Authorizer the annotation alone would admit anyone, so the
112
+ * rule is load-bearing in open source.
113
+ */
114
+ export function isPlatformPipelineCaller(caller: CallerIdentity): boolean {
115
+ return (
116
+ caller.callerClass === "machine" ||
117
+ caller.callerClass === "internal" ||
118
+ caller.origin === "in-process"
119
+ );
120
+ }
@@ -71,21 +71,61 @@
71
71
  * untouched with the org unconsulted (the first line above). Two lanes
72
72
  * have no org on the wire (`session.list`, `apiKey.findAll`) and offer
73
73
  * the kind until P1 entry 12 pages them.
74
+ * - A KIND WHOSE AUTHORIZATION IS ITS PARENT'S CARRIES THAT PARENT ON
75
+ * EVERY CANDIDATE (stigmer-cloud 20260913.04 T04, Q-LB-33..36). When
76
+ * `kind_meta` declares PARENT scope with INHERITED owner
77
+ * (`inheritedAuthorizationParentOf`; today agent_execution → session),
78
+ * the helper fills `ListEntryMeta.authorizationParent` from the row's
79
+ * spec through the same read the tuple lifecycle wrote the parent link
80
+ * with. A driver MAY answer the parent's question in place of the
81
+ * child's: by the model's construction the two answers are identical
82
+ * (the child's `owner`, `viewer` and `can_view` are all `from` the
83
+ * parent), and the parent's resolution is direct tuples where the
84
+ * child's walks the parent's whole set (OpenFGA's learned `weight2`
85
+ * strategy read the founder's 423 sessions per execution; that is what
86
+ * rolled back twice on 2026-09-14). A driver that ignores the field is
87
+ * still correct. The one observable difference is stated, not hidden:
88
+ * a row whose parent tuple never landed (the create chain's "row
89
+ * survives, tuple write failed" state, healed by retry) is hidden by
90
+ * the child's check and shown by the parent's — the row's truth, read
91
+ * from the field the tuple was derived from, and no widening: nobody
92
+ * reaches a parent through it who could not create the row in it.
74
93
  */
75
94
  import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
76
95
 
96
+ // The first VALUE imports from extensions/ into pipeline/ (every other
97
+ // crossing is a type): two pure leaf modules that import nothing but
98
+ // protos, so there is no cycle. Deliberate — one resolver for the tuple
99
+ // writer and the list reader beats a copy in the seam.
100
+ import {
101
+ getKindEnum,
102
+ inheritedAuthorizationParentOf,
103
+ } from "../pipeline/apiresource-meta.js";
104
+ import { parentIdOf } from "../pipeline/steps/shapes.js";
105
+
77
106
  import type { CallerIdentity } from "./identity.js";
107
+ import type { ResolvedParentLink } from "./resource-authorization.js";
78
108
 
79
109
  /**
80
110
  * The candidate metadata a post-scan lane offers the scope — the three
81
111
  * fields the cloud driver's rules key on (id for the FGA intersection,
82
112
  * labels for the guest cookie rule; org rides along for symmetry with
83
- * the consumer-owned intersection, so a driver can log or assert on it).
113
+ * the consumer-owned intersection, so a driver can log or assert on it),
114
+ * plus the parent for kinds whose authorization is their parent's.
84
115
  */
85
116
  export interface ListEntryMeta {
86
117
  readonly id: string;
87
118
  readonly org: string;
88
119
  readonly labels: Readonly<Record<string, string>>;
120
+ /**
121
+ * OPTIONAL (added 20260913.04 T04): the parent this row's authorization
122
+ * IS — present only for a kind whose `kind_meta` is PARENT scope with
123
+ * INHERITED owner (the header's last contract line), the same
124
+ * `ResolvedParentLink` the tuple lifecycle wrote for the row. Absent for
125
+ * every other kind, and absent when the row's spec does not name its
126
+ * parent — a driver then asks about the row itself, never drops it.
127
+ */
128
+ readonly authorizationParent?: ResolvedParentLink;
89
129
  }
90
130
 
91
131
  /** The scope contract (single-instance point, ExtensionDrivers.listReadScope). */
@@ -117,7 +157,10 @@ export interface ListReadScope {
117
157
 
118
158
  /**
119
159
  * The shape every stored API resource message presents to the shared
120
- * helper — the metadata fields the scope's candidates are built from.
160
+ * helper — the metadata fields the scope's candidates are built from, and
161
+ * the spec the parent is read from (structurally, by `kind_meta`'s
162
+ * `spec_field`; typed loosely because the field differs per kind and the
163
+ * read is `parentIdOf`'s).
121
164
  */
122
165
  export interface ScopedListResource {
123
166
  metadata?: {
@@ -125,6 +168,7 @@ export interface ScopedListResource {
125
168
  org?: string;
126
169
  labels?: Record<string, string>;
127
170
  };
171
+ spec?: object;
128
172
  }
129
173
 
130
174
  /**
@@ -141,8 +185,11 @@ export interface ScopedListResource {
141
185
  * permission-bounded across orgs, verified per lane in the entry's
142
186
  * census — lanes Java does not org-narrow pass ""), then to the kept
143
187
  * ids. The same set either way (both are per-row predicates); the
144
- * order is the header's last contract line: the scope sees the org's
145
- * rows, not the kind's.
188
+ * order is the header's second-to-last contract line: the scope sees
189
+ * the org's rows, not the kind's;
190
+ * - the candidates of a kind whose authorization is its parent's carry
191
+ * `authorizationParent` (the header's last line), resolved once per
192
+ * call from `kind_meta` and once per row from the spec.
146
193
  */
147
194
  export async function restrictListByReadScope<T extends ScopedListResource>(
148
195
  scope: ListReadScope | undefined,
@@ -158,11 +205,42 @@ export async function restrictListByReadScope<T extends ScopedListResource>(
158
205
  requestOrg === ""
159
206
  ? resources
160
207
  : resources.filter((resource) => resource.metadata?.org === requestOrg);
161
- const entries: ListEntryMeta[] = offered.map((resource) => ({
162
- id: resource.metadata?.id ?? "",
163
- org: resource.metadata?.org ?? "",
164
- labels: resource.metadata?.labels ?? {},
165
- }));
208
+ const parentOf = authorizationParentReader(kind);
209
+ const entries: ListEntryMeta[] = offered.map((resource) => {
210
+ const entry: ListEntryMeta = {
211
+ id: resource.metadata?.id ?? "",
212
+ org: resource.metadata?.org ?? "",
213
+ labels: resource.metadata?.labels ?? {},
214
+ };
215
+ const authorizationParent = parentOf(resource);
216
+ return authorizationParent === undefined
217
+ ? entry
218
+ : { ...entry, authorizationParent };
219
+ });
166
220
  const keptIds = await scope.restrictListEntries(caller, kind, entries);
167
221
  return offered.filter((resource) => keptIds.has(resource.metadata?.id ?? ""));
168
222
  }
223
+
224
+ /**
225
+ * The per-row parent reader for `kind`: a constant `undefined` for the 27
226
+ * kinds whose authorization is their own, and for the kind whose
227
+ * authorization is its parent's, the `ResolvedParentLink` the tuple
228
+ * lifecycle wrote — or undefined for a row whose spec does not name it
229
+ * (never a throw: a list is not a create). `kind_meta` is consulted once
230
+ * per call, not once per row.
231
+ */
232
+ function authorizationParentReader(
233
+ kind: ApiResourceKind,
234
+ ): (resource: ScopedListResource) => ResolvedParentLink | undefined {
235
+ const parent = inheritedAuthorizationParentOf(kind);
236
+ if (parent === undefined) {
237
+ return () => undefined;
238
+ }
239
+ const parentKind = getKindEnum(parent.kind);
240
+ return (resource) => {
241
+ const parentId = parentIdOf(resource, parent.specField);
242
+ return parentId === ""
243
+ ? undefined
244
+ : { relation: parent.relation, parentKind, parentId };
245
+ };
246
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The policy-grant-scope driver point (20260913.01; the P1 gate's Q7 iii,
3
+ * T01_1_review.md Q-OR-3): WHICH kinds a user may grant a role on in this
4
+ * edition, and with which roles. Single instance, registered as
5
+ * `drivers.policyGrantScope` (the identityFederation shape: a driver point
6
+ * whose absence is open source's own behaviour). Absent, the composition
7
+ * root installs `newOrganizationOnlyGrantScope()`
8
+ * (domain/iampolicy/grant-scope.ts): the organization grants the roles its
9
+ * `kind_meta` lists — owner, admin, member, viewer (Q-OR-4) — and no other
10
+ * kind grants anything. Per-resource grants (a viewer on one agent) are
11
+ * what the Enterprise and Cloud editions add by registering a wider scope;
12
+ * the cloud registers every kind's `kind_meta` roles.
13
+ *
14
+ * Who reads it: the IamPolicy command controller's ValidateGrantableRole on
15
+ * `create` — AFTER the proto (`grantableRolesFor` in
16
+ * pipeline/apiresource-meta.ts, the source of "what can be granted at
17
+ * all"; a kind that lists no roles is system-managed in every edition and
18
+ * refused before the scope is asked) and never on `bootstrapPolicy`, the
19
+ * structural lane — and `checkMyPermission`'s second arm, which answers
20
+ * `can_grant_access` false for a kind outside the scope so the console's
21
+ * existing PermissionGate hides grant controls without a new SDK surface
22
+ * (Q-OR-5).
23
+ *
24
+ * The contract every scope is held to (Q-S3-3, 2026-09-13):
25
+ *
26
+ * - It NARROWS the proto, never widens it. A scope's answer for a kind is
27
+ * read as a subset of `kind_meta.authorization.grantable_roles`;
28
+ * ValidateGrantableRole intersects the two, so a driver cannot admit a
29
+ * role the contract does not list by accident.
30
+ * - It is TOTAL over ApiResourceKind, the unknown kind included: a kind
31
+ * it does not admit answers the empty list, never a throw.
32
+ * checkMyPermission asks about a kind that came off the wire and was
33
+ * resolved by `kindByEnumName`, which yields the unknown kind rather
34
+ * than failing (apiresource-meta.ts header).
35
+ * - It is SYNCHRONOUS: an edition's grant scope is a fact about the
36
+ * edition, a proto read at most, never I/O. A scope that would need a
37
+ * network to answer is a different seam.
38
+ */
39
+ import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
40
+ import type { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
41
+
42
+ /** The grant-scope contract (single-instance point, ExtensionDrivers.policyGrantScope). */
43
+ export interface PolicyGrantScope {
44
+ /**
45
+ * The roles a user may grant on a resource of `kind` in this edition — a
46
+ * subset of the kind's proto `grantable_roles`; empty when the edition
47
+ * grants nothing on the kind (or the kind is unknown). Never throws.
48
+ */
49
+ grantableRoles(kind: ApiResourceKind): ReadonlyArray<IamRole>;
50
+ }
@@ -34,7 +34,10 @@
34
34
  * points (20260911.11) at the identity-accounts stage (the store driver,
35
35
  * ahead of the boot-time operator ensure) and the routes stage (the
36
36
  * federation capability and the provision slot's steps, into the
37
- * identity-account controller).
37
+ * identity-account controller); the IamPolicy points (20260913.01) at
38
+ * the same two stages — the store driver where the grant path is built,
39
+ * beside the identity-account store, and the grant scope and the query
40
+ * engine into the IamPolicy controller at the routes stage.
38
41
  */
39
42
  import type { DescMessage } from "@bufbuild/protobuf";
40
43
  import type { ConnectRouter } from "@connectrpc/connect";
@@ -45,10 +48,13 @@ import type { ArtifactStorageDriverFactory } from "../artifactstorage/artifact-s
45
48
  import { BUILT_IN_STORAGE_TYPES } from "../artifactstorage/artifact-storage.js";
46
49
  import type { ChannelRuntime } from "../domain/agentchannel/channel-runtime.js";
47
50
  import type { IdentityAccountStore } from "../domain/identityaccount/store.js";
51
+ import type { IamPolicyStore } from "../domain/iampolicy/store.js";
48
52
  import type { SecretCodec } from "../encryption/codec.js";
49
53
  import { V1_VERSION } from "../encryption/v1-codec.js";
54
+ import type { AuthorizationQueryEngine } from "./authorization-queries.js";
50
55
  import type { IdentityFederation } from "./identity-federation.js";
51
56
  import type { ListReadScope } from "./list-read-scope.js";
57
+ import type { PolicyGrantScope } from "./policy-grant-scope.js";
52
58
  import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
53
59
  import type { VisitorErrorPolicy } from "../pipeline/interceptors/error-boundary.js";
54
60
  import type { PipelineStep } from "../pipeline/pipeline.js";
@@ -250,6 +256,22 @@ export interface ResolvedExtensionDrivers {
250
256
  * four federated RPCs refuse UNIMPLEMENTED with the edition reason.
251
257
  */
252
258
  readonly identityFederation: IdentityFederation | undefined;
259
+ /**
260
+ * The 20260913.01 IamPolicy store driver — undefined = the compose.ts
261
+ * stage that builds the grant path installs the OSS adapter over the
262
+ * generic Store, OSS behavior byte-identical.
263
+ */
264
+ readonly iamPolicyStore: IamPolicyStore | undefined;
265
+ /**
266
+ * The 20260913.01 policy grant scope — undefined = compose.ts installs
267
+ * open source's organization-only default.
268
+ */
269
+ readonly policyGrantScope: PolicyGrantScope | undefined;
270
+ /**
271
+ * The 20260913.01 authorization-query engine — undefined = the
272
+ * tuple-half RPC arms refuse UNIMPLEMENTED with the edition reason.
273
+ */
274
+ readonly authorizationQueries: AuthorizationQueryEngine | undefined;
253
275
  }
254
276
 
255
277
  /**
@@ -297,6 +319,12 @@ export function resolveExtensions(
297
319
  let identityAccountStoreDeclaredBy: string | undefined;
298
320
  let identityFederation: IdentityFederation | undefined;
299
321
  let identityFederationDeclaredBy: string | undefined;
322
+ let iamPolicyStore: IamPolicyStore | undefined;
323
+ let iamPolicyStoreDeclaredBy: string | undefined;
324
+ let policyGrantScope: PolicyGrantScope | undefined;
325
+ let policyGrantScopeDeclaredBy: string | undefined;
326
+ let authorizationQueries: AuthorizationQueryEngine | undefined;
327
+ let authorizationQueriesDeclaredBy: string | undefined;
300
328
  const artifactStorageDrivers = new Map<
301
329
  string,
302
330
  ArtifactStorageDriverFactory
@@ -458,6 +486,36 @@ export function resolveExtensions(
458
486
  identityFederationDeclaredBy = unit.name;
459
487
  }
460
488
 
489
+ if (unit.drivers?.iamPolicyStore !== undefined) {
490
+ if (iamPolicyStoreDeclaredBy !== undefined) {
491
+ throw new Error(
492
+ `extension '${unit.name}' registers an IamPolicyStore, but '${iamPolicyStoreDeclaredBy}' already did — exactly one may be composed`,
493
+ );
494
+ }
495
+ iamPolicyStore = unit.drivers.iamPolicyStore;
496
+ iamPolicyStoreDeclaredBy = unit.name;
497
+ }
498
+
499
+ if (unit.drivers?.policyGrantScope !== undefined) {
500
+ if (policyGrantScopeDeclaredBy !== undefined) {
501
+ throw new Error(
502
+ `extension '${unit.name}' registers a PolicyGrantScope, but '${policyGrantScopeDeclaredBy}' already did — exactly one may be composed`,
503
+ );
504
+ }
505
+ policyGrantScope = unit.drivers.policyGrantScope;
506
+ policyGrantScopeDeclaredBy = unit.name;
507
+ }
508
+
509
+ if (unit.drivers?.authorizationQueries !== undefined) {
510
+ if (authorizationQueriesDeclaredBy !== undefined) {
511
+ throw new Error(
512
+ `extension '${unit.name}' registers an AuthorizationQueryEngine, but '${authorizationQueriesDeclaredBy}' already did — exactly one may be composed`,
513
+ );
514
+ }
515
+ authorizationQueries = unit.drivers.authorizationQueries;
516
+ authorizationQueriesDeclaredBy = unit.name;
517
+ }
518
+
461
519
  if (unit.drivers?.artifactStorageDrivers !== undefined) {
462
520
  for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
463
521
  if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
@@ -580,6 +638,9 @@ export function resolveExtensions(
580
638
  scheduleFireCaller,
581
639
  identityAccountStore,
582
640
  identityFederation,
641
+ iamPolicyStore,
642
+ policyGrantScope,
643
+ authorizationQueries,
583
644
  },
584
645
  services,
585
646
  workers,
@@ -26,9 +26,37 @@
26
26
  * - onVisibilityChanged: SYNCHRONOUS, post-persist; a throw fails the
27
27
  * request (metadata may be persisted with tuples lagging — retrying
28
28
  * the same transition converges, the set-diff is idempotent).
29
+ *
30
+ * Corrected 2026-09-13 (20260913.01, T01_1_review.md Q-OR-6, Q-OR-10):
31
+ * "no driver composed = OSS behavior byte-identical" described the seam
32
+ * while the only writer of authorization records was the cloud's tuple
33
+ * driver. The seam now records authorization at lifecycle points in EVERY
34
+ * edition: open source's built-in role lifecycle
35
+ * (domain/iampolicy/role-lifecycle.ts, the entry's slice 4) writes
36
+ * IamPolicy rows (the organization creator's `owner` row; cleanup on
37
+ * delete), and a composed driver writes tuples too. The two POLICY hooks below are the
38
+ * other direction of the same seam — the IamPolicy domain's one grant and
39
+ * revoke path (domain/iampolicy/grant-path.ts) telling the driver that a
40
+ * row was written or is about to be deleted, so a composition can mirror
41
+ * the row as a tuple. Their order is the cloud#425 invariant, fixed by the
42
+ * path and stated on each event:
43
+ * - onPolicyGranted: SYNCHRONOUS, AFTER the row is persisted; fires on
44
+ * the duplicate arm too (the inline heal for a row whose tuple never
45
+ * landed). A throw fails the request with the row in place — the
46
+ * caller's retry or a boot backfill heals it.
47
+ * - onPolicyRevoked: SYNCHRONOUS, BEFORE the row is deleted. A throw
48
+ * leaves row and tuple, so a retry converges; the reverse order was
49
+ * the fail-open incident class ("revoke" silently becoming "keep").
50
+ * On the absent arm (no row for the spec) it fires with the spec and
51
+ * no policy, so a composition can still delete a bare tuple written
52
+ * before the row mirror existed.
53
+ * Absent method = no mirror is written (the OSS posture: rows are the
54
+ * record).
29
55
  */
30
56
  import type { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
31
57
  import type { OwnerAttributionType } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/authorization_config_pb";
58
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
59
+ import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
32
60
 
33
61
  import type { CallerIdentity } from "./identity.js";
34
62
 
@@ -125,6 +153,31 @@ export interface DefaultInstanceLinkedEvent {
125
153
  readonly blueprintId: string;
126
154
  }
127
155
 
156
+ /**
157
+ * Fired synchronously AFTER an IamPolicy row is persisted by the domain's
158
+ * grant path — and on the duplicate arm, when the triple was already held
159
+ * and no row was written, so a composition heals a row whose tuple never
160
+ * landed (cloud#425's inline heal). `policy` is the row as stored: on the
161
+ * duplicate arm the FIRST writer's row, whose id may be a legacy random
162
+ * one on a composition that predates derived ids.
163
+ */
164
+ export interface PolicyGrantedEvent {
165
+ readonly policy: IamPolicy;
166
+ /** True when the triple was already held and this grant wrote nothing. */
167
+ readonly duplicate: boolean;
168
+ }
169
+
170
+ /**
171
+ * Fired synchronously BEFORE an IamPolicy row is deleted by the domain's
172
+ * grant path. `spec` is always the triple being revoked; `policy` is the
173
+ * row found for it, or `undefined` on the absent arm — the spec alone is
174
+ * enough to name the tuple a composition still deletes then.
175
+ */
176
+ export interface PolicyRevokedEvent {
177
+ readonly spec: IamPolicySpec;
178
+ readonly policy: IamPolicy | undefined;
179
+ }
180
+
128
181
  /**
129
182
  * The driver interface (single-instance point, registered via
130
183
  * ExtensionDrivers.resourceAuthorizationLifecycle). Implementations must
@@ -142,4 +195,16 @@ export interface ResourceAuthorizationLifecycle {
142
195
  * (the OSS posture: local access control needs none).
143
196
  */
144
197
  onDefaultInstanceLinked?(event: DefaultInstanceLinkedEvent): Promise<void>;
198
+ /**
199
+ * OPTIONAL (added 20260913.01 slice 2): synchronous, AFTER the row
200
+ * persist, on the duplicate arm too; a throw fails the grant with the
201
+ * row in place. Absent method = rows are the record (the OSS posture).
202
+ */
203
+ onPolicyGranted?(event: PolicyGrantedEvent): Promise<void>;
204
+ /**
205
+ * OPTIONAL (added 20260913.01 slice 2): synchronous, BEFORE the row
206
+ * delete; a throw leaves row and tuple for the retry. Absent method =
207
+ * rows are the record (the OSS posture).
208
+ */
209
+ onPolicyRevoked?(event: PolicyRevokedEvent): Promise<void>;
145
210
  }
package/src/index.ts CHANGED
@@ -100,6 +100,8 @@ export type { ResolvedExtensionDrivers } from "./extensions/registry.js";
100
100
  // tests pin against.
101
101
  export type {
102
102
  DefaultInstanceLinkedEvent,
103
+ PolicyGrantedEvent,
104
+ PolicyRevokedEvent,
103
105
  ResourceAuthorizationLifecycle,
104
106
  ResourceCreatedEvent,
105
107
  ResourceDeletedEvent,
@@ -125,6 +127,24 @@ export type {
125
127
  IdentityAccountStoreContractFixture,
126
128
  } from "./domain/identityaccount/store-contract.js";
127
129
  export { identityAccountStoreContract } from "./domain/identityaccount/store-contract.js";
130
+ // The 20260913.01 IamPolicy seams (Q-OR-1, Q-OR-10): the store PORT a
131
+ // composition drives the domain's grant path through (drivers.iamPolicyStore;
132
+ // a driver throws DuplicatePolicyError for a held id) and its vitest-free
133
+ // contract kit; the grant scope (drivers.policyGrantScope — which kinds an
134
+ // edition grants on, with which roles; open source's default is the
135
+ // organization alone); and the tuple-half query engine
136
+ // (drivers.authorizationQueries — the three graph questions only an
137
+ // authorization backend answers). The grant path itself is NOT exported: a
138
+ // composition reaches it as in-process RPCs, the doctrine.
139
+ export type { IamPolicyStore } from "./domain/iampolicy/store.js";
140
+ export { DuplicatePolicyError } from "./domain/iampolicy/store.js";
141
+ export type {
142
+ IamPolicyStoreContractCase,
143
+ IamPolicyStoreContractFixture,
144
+ } from "./domain/iampolicy/store-contract.js";
145
+ export { iamPolicyStoreContract } from "./domain/iampolicy/store-contract.js";
146
+ export type { PolicyGrantScope } from "./extensions/policy-grant-scope.js";
147
+ export type { AuthorizationQueryEngine } from "./extensions/authorization-queries.js";
128
148
  export type { IdentityFederation } from "./extensions/identity-federation.js";
129
149
  export type { AccountsBySubject } from "./domain/identityaccount/resolve.js";
130
150
  export { identityIdForSubject } from "./domain/identityaccount/resolve.js";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Pins `grantableRolesFor` (pipeline/apiresource-meta.ts), the one read of
3
+ * a kind's `kind_meta.authorization.grantable_roles` — what the IamPolicy
4
+ * domain's ValidateGrantableRole consults FIRST (20260913.01, Q-OR-3 as
5
+ * refined: the proto says what can be granted at all; the composed
6
+ * PolicyGrantScope only narrows by edition) and what open source's
7
+ * organization-only scope reads for the organization.
8
+ *
9
+ * Three properties are load-bearing and pinned here rather than assumed:
10
+ *
11
+ * - The ORGANIZATION lists exactly owner, admin, member, viewer, in the
12
+ * proto's order. This is the Q-OR-4 ruling made a test: the four roles
13
+ * every edition grants on an organization are the proto's, not a list
14
+ * in code, so a change to `api_resource_kind.proto` fails here and
15
+ * someone reads the ruling before the console shows a fifth word.
16
+ * - The five system-managed kinds list NO roles. ValidateGrantableRole's
17
+ * first arm refuses a grant on them with the cloud's byte-pinned
18
+ * INVALID_ARGUMENT in every edition; this is the data that arm reads.
19
+ * - TOTAL, never a throw. The reader is asked about the SECOND kind
20
+ * vocabulary — an `ApiResourceRef.kind` resolved through
21
+ * `kindByEnumName`, which yields the unknown kind for anything it does
22
+ * not recognise and never throws (the module header). The unknown kind
23
+ * is the one enum member without `kind_meta`; it answers the empty
24
+ * list, as the cloud's `grantableRolesFor` (iam/policy/roles.ts) did.
25
+ */
26
+ import { getOption, hasOption } from "@bufbuild/protobuf";
27
+ import { describe, expect, it } from "vitest";
28
+
29
+ import {
30
+ ApiResourceKind,
31
+ ApiResourceKindSchema,
32
+ kind_meta,
33
+ } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
34
+ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
35
+
36
+ import { grantableRolesFor } from "../apiresource-meta.js";
37
+
38
+ describe("grantableRolesFor — the proto's grantable roles per kind", () => {
39
+ it("the organization grants exactly owner, admin, member, viewer, in proto order (Q-OR-4)", () => {
40
+ expect(grantableRolesFor(ApiResourceKind.organization)).toEqual([
41
+ IamRole.owner,
42
+ IamRole.admin,
43
+ IamRole.member,
44
+ IamRole.viewer,
45
+ ]);
46
+ });
47
+
48
+ it("a blueprint kind answers the descriptor's own list, not a copy", () => {
49
+ const agentValue = ApiResourceKindSchema.values.find(
50
+ (value) => value.number === ApiResourceKind.agent,
51
+ );
52
+ expect(agentValue).toBeDefined();
53
+ const fromDescriptor =
54
+ getOption(agentValue!, kind_meta).authorization?.grantableRoles ?? [];
55
+ expect(fromDescriptor.length).toBeGreaterThan(0);
56
+ expect(grantableRolesFor(ApiResourceKind.agent)).toEqual(fromDescriptor);
57
+ });
58
+
59
+ it.each([
60
+ ["identity_account", ApiResourceKind.identity_account],
61
+ ["api_key", ApiResourceKind.api_key],
62
+ ["iam_policy", ApiResourceKind.iam_policy],
63
+ ["invitation", ApiResourceKind.invitation],
64
+ ["agent_execution", ApiResourceKind.agent_execution],
65
+ ])(
66
+ "%s is system-managed: no grantable roles (Q-OR-3 arm 1)",
67
+ (_name, kind) => {
68
+ expect(grantableRolesFor(kind)).toEqual([]);
69
+ },
70
+ );
71
+
72
+ it("the unknown kind answers the empty list and never throws (the second vocabulary's doctrine)", () => {
73
+ expect(
74
+ grantableRolesFor(ApiResourceKind.api_resource_kind_unknown),
75
+ ).toEqual([]);
76
+ });
77
+
78
+ it("is total over the descriptor: every member answers an array", () => {
79
+ for (const value of ApiResourceKindSchema.values) {
80
+ const roles = grantableRolesFor(value.number as ApiResourceKind);
81
+ expect(Array.isArray(roles), value.name).toBe(true);
82
+ if (!hasOption(value, kind_meta)) {
83
+ expect(roles, `${value.name} carries no kind_meta`).toEqual([]);
84
+ }
85
+ }
86
+ });
87
+ });